// Author: fruit.com.cn import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; const double scrubPreviewWidth = 120; const double scrubPreviewSafeMargin = 12; /// 把预览卡片限制在进度条可用宽度内。 double scrubPreviewLeft({ required double trackWidth, required double fraction, double cardWidth = scrubPreviewWidth, double safeMargin = scrubPreviewSafeMargin, }) { final maxLeft = trackWidth - cardWidth - safeMargin; if (maxLeft < safeMargin) { return ((trackWidth - cardWidth) / 2).clamp(0.0, trackWidth).toDouble(); } final requested = trackWidth * fraction.clamp(0.0, 1.0) - cardWidth / 2; return requested.clamp(safeMargin, maxLeft).toDouble(); } /// 拖动进度时覆盖在轨道上方的单帧预览。 class ScrubPreviewCard extends StatefulWidget { const ScrubPreviewCard({ super.key, required this.service, required this.episodeId, required this.posterUrl, required this.position, required this.duration, }); final PlaybackThumbnailService service; final String episodeId; final String posterUrl; final Duration position; final Duration duration; @override State createState() => _ScrubPreviewCardState(); } class _ScrubPreviewCardState extends State { ByteData? _frame; int _requestSerial = 0; int? _requestedBucket; @override void initState() { super.initState(); _requestFrame(); } @override void didUpdateWidget(covariant ScrubPreviewCard oldWidget) { super.didUpdateWidget(oldWidget); final bucket = playbackThumbnailBucket(widget.position); if (oldWidget.service != widget.service || oldWidget.episodeId != widget.episodeId || _requestedBucket != bucket) { _frame = null; _requestFrame(); } else { // 同 bucket 内继续拖动时不重复取帧,但会重置 120ms 预取停顿计时。 unawaited(widget.service.thumbnail(widget.episodeId, widget.position)); } } @override void dispose() { _requestSerial++; super.dispose(); } void _requestFrame() { final bucket = playbackThumbnailBucket(widget.position); _requestedBucket = bucket; final serial = ++_requestSerial; final future = widget.service.thumbnail(widget.episodeId, widget.position); future.then((frame) { if (!mounted || serial != _requestSerial) return; setState(() => _frame = frame); }); } @override Widget build(BuildContext context) { return Container( key: const Key('scrub-preview-card'), width: scrubPreviewWidth, decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.white, width: 1), ), child: ClipRRect( borderRadius: BorderRadius.circular(7), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), color: Colors.black87, child: Text( '${_format(widget.position)} / ${_format(widget.duration)}', key: const Key('scrub-preview-time'), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600, height: 1.2, ), ), ), AspectRatio( aspectRatio: 16 / 9, child: Stack( fit: StackFit.expand, children: [ RemoteImage( widget.posterUrl, key: const Key('scrub-preview-poster'), fit: BoxFit.cover, fallbackColor: AppColors.bar, ), if (_frame case final frame?) Image.memory( frame.buffer.asUint8List( frame.offsetInBytes, frame.lengthInBytes, ), key: const Key('scrub-preview-frame'), fit: BoxFit.cover, cacheWidth: 240, gaplessPlayback: true, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ], ), ), ], ), ), ); } } String _format(Duration value) { final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; }