// Author: fruit.com.cn import 'dart:async'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; const double scrubPreviewWidth = 96; const double scrubPreviewSafeMargin = 12; const double _portraitFrameAspect = 9 / 16; /// 把预览卡片限制在进度条可用宽度内。 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.position, required this.duration, this.spriteAxisService, this.thumbnailsUrl, }); final PlaybackThumbnailService service; final SpriteThumbnailAxisSource? spriteAxisService; final String? thumbnailsUrl; final String episodeId; final Duration position; final Duration duration; @override State createState() => _ScrubPreviewCardState(); } class _ScrubPreviewCardState extends State { ByteData? _fallbackFrame; int _requestSerial = 0; int? _requestedBucket; String? _requestedEpisodeId; @override void initState() { super.initState(); widget.spriteAxisService?.addListener(_handleSpriteAxisChanged); _prepareSprite(); _syncFallbackSource(); } @override void didUpdateWidget(covariant ScrubPreviewCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.spriteAxisService != widget.spriteAxisService) { oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged); widget.spriteAxisService?.addListener(_handleSpriteAxisChanged); } if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl || oldWidget.position != widget.position) { _prepareSprite(); } if (oldWidget.service != widget.service || oldWidget.episodeId != widget.episodeId || oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { _resetFallback(); _syncFallbackSource(); return; } if (_requestedBucket != playbackThumbnailBucket(widget.position)) { _fallbackFrame = null; _syncFallbackSource(); } else if (_fallbackFrame != null || _shouldUseLocalFallback()) { unawaited(widget.service.thumbnail(widget.episodeId, widget.position)); } } @override void dispose() { widget.spriteAxisService?.removeListener(_handleSpriteAxisChanged); _requestSerial++; widget.service.cancelPending(); super.dispose(); } void _prepareSprite() { final service = widget.spriteAxisService; if (service == null) return; unawaited(service.prepareAt(widget.thumbnailsUrl, widget.position)); } void _handleSpriteAxisChanged() { if (!mounted) return; if (_spriteFrame != null) { widget.service.cancelPending(); } setState(() {}); _syncFallbackSource(); } SpriteThumbnailFrame? get _spriteFrame => widget.spriteAxisService ?.cachedFrameAt(widget.thumbnailsUrl, widget.position); bool _shouldUseLocalFallback() { if (_spriteFrame != null) return false; final url = widget.thumbnailsUrl?.trim() ?? ''; if (url.isEmpty || widget.spriteAxisService == null) return true; return widget.spriteAxisService!.hasAxis(widget.thumbnailsUrl); } void _syncFallbackSource() { if (_spriteFrame != null) { widget.service.cancelPending(); return; } if (!_shouldUseLocalFallback()) return; _requestFallbackFrame(); } void _requestFallbackFrame() { final bucket = playbackThumbnailBucket(widget.position); if (_requestedBucket == bucket && _requestedEpisodeId == widget.episodeId && (_fallbackFrame != null || _requestSerial > 0)) { return; } _requestedBucket = bucket; _requestedEpisodeId = widget.episodeId; final serial = ++_requestSerial; widget.service.thumbnail(widget.episodeId, widget.position).then((frame) { if (!mounted || serial != _requestSerial || _spriteFrame != null) return; setState(() => _fallbackFrame = frame); }); } void _resetFallback() { widget.service.cancelPending(); _fallbackFrame = null; _requestedBucket = null; _requestedEpisodeId = null; _requestSerial++; } @override Widget build(BuildContext context) { final spriteFrame = _spriteFrame; final stage = decideScrubPreviewStage( hasThumbnailsUrl: (widget.thumbnailsUrl?.trim().isNotEmpty ?? false), hasSpriteFrame: spriteFrame != null, hasAxis: widget.spriteAxisService?.hasAxis(widget.thumbnailsUrl) ?? false, hasLocalFrame: _fallbackFrame != null, ); return Container( key: const Key('scrub-preview-card'), width: scrubPreviewWidth, decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(8), border: Border.all(color: AppColors.onPrimary, 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: AppColors.scrubPreviewScrim, 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: AppColors.onPrimary, fontSize: 11, fontWeight: FontWeight.w600, height: 1.2, ), ), ), AspectRatio( key: const Key('scrub-preview-frame-box'), aspectRatio: _frameAspect(spriteFrame), child: Stack( fit: StackFit.expand, children: [ const ColoredBox( key: Key('scrub-preview-background'), color: AppColors.bar, ), if (stage == ScrubPreviewStage.sprite && spriteFrame != null) SpriteCropPreview( key: const Key('scrub-preview-sprite'), image: spriteFrame.image, sourceRect: spriteFrame.sourceRect, ), if (stage == ScrubPreviewStage.localFrame && _fallbackFrame != null) Image.memory( _fallbackFrame!.buffer.asUint8List( _fallbackFrame!.offsetInBytes, _fallbackFrame!.lengthInBytes, ), key: const Key('scrub-preview-frame'), fit: BoxFit.cover, cacheWidth: 240, gaplessPlayback: true, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ], ), ), ], ), ), ); } } double _frameAspect(SpriteThumbnailFrame? frame) { final rect = frame?.sourceRect; if (rect == null || rect.width <= 0 || rect.height <= 0) { return _portraitFrameAspect; } return rect.width / rect.height; } /// 对已解码 sprite 做 sourceRect 裁剪,不触发再次解码。 class SpriteCropPreview extends StatelessWidget { const SpriteCropPreview({ super.key, required this.image, required this.sourceRect, this.fit = BoxFit.cover, }); final ui.Image image; final ui.Rect sourceRect; final BoxFit fit; @override Widget build(BuildContext context) => CustomPaint( painter: _SpriteCropPainter(image: image, sourceRect: sourceRect, fit: fit), ); } class _SpriteCropPainter extends CustomPainter { const _SpriteCropPainter({ required this.image, required this.sourceRect, required this.fit, }); final ui.Image image; final ui.Rect sourceRect; final BoxFit fit; @override void paint(Canvas canvas, Size size) { final fitted = applyBoxFit(fit, sourceRect.size, size); final sourceOffset = Alignment.center.inscribe( fitted.source, Offset.zero & sourceRect.size, ); final source = Rect.fromLTWH( sourceRect.left + sourceOffset.left, sourceRect.top + sourceOffset.top, fitted.source.width, fitted.source.height, ); final destination = Alignment.center.inscribe( fitted.destination, Offset.zero & size, ); canvas.drawImageRect( image, source, destination, Paint() ..filterQuality = FilterQuality.low ..isAntiAlias = false, ); } @override bool shouldRepaint(covariant _SpriteCropPainter oldDelegate) => oldDelegate.image != image || oldDelegate.sourceRect != sourceRect || oldDelegate.fit != fit; } 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'; }