diff --git a/lib/core/data/services/sprite_thumbnail_axis.dart b/lib/core/data/services/sprite_thumbnail_axis.dart index 55f10da..e6f7080 100644 --- a/lib/core/data/services/sprite_thumbnail_axis.dart +++ b/lib/core/data/services/sprite_thumbnail_axis.dart @@ -132,36 +132,37 @@ class SpriteThumbnailAxisService extends ChangeNotifier SpriteThumbnailAxisService({ SpriteThumbnailAssetLoader? loader, this.cacheTtl = spriteVttCacheTtl, + this.spriteCacheCapacity = spriteImageCacheCapacity, DateTime Function()? now, }) : _loader = loader ?? DioSpriteThumbnailAssetLoader(), _now = now ?? DateTime.now; final SpriteThumbnailAssetLoader _loader; final Duration cacheTtl; + final int spriteCacheCapacity; final DateTime Function() _now; final Map _axisCache = {}; final Map> _axisRequests = {}; final LinkedHashMap _spriteCache = LinkedHashMap(); final Map> _spriteRequests = {}; final Set _failedSprites = {}; - String? _activeAxisUrl; - int _axisGeneration = 0; bool _disposed = false; @visibleForTesting int get spriteCacheSize => _spriteCache.length; @visibleForTesting - List get spriteCacheKeys => _spriteCache.keys.toList(); + List get spriteCacheKeys => _spriteCache.keys + .map((url) => Uri.tryParse(url)?.pathSegments.lastOrNull ?? url) + .toList(); /// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。 @override Future prepareAt(String? thumbnailsUrl, Duration position) async { final url = _normalizeUrl(thumbnailsUrl); if (_disposed || url == null) return; - _activateAxis(url); final axis = await _loadAxis(url); - if (_disposed || _activeAxisUrl != url) return; + if (_disposed) return; for (final cue in _prefetchCues(axis, position)) { await _ensureSprite(axis, cue); } @@ -170,7 +171,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier @override bool hasAxis(String? thumbnailsUrl) { final url = _normalizeUrl(thumbnailsUrl); - if (url == null || url != _activeAxisUrl) return false; + if (url == null) return false; return _readAxis(url) != null; } @@ -180,12 +181,14 @@ class SpriteThumbnailAxisService extends ChangeNotifier Duration position, ) { final url = _normalizeUrl(thumbnailsUrl); - if (url == null || url != _activeAxisUrl) return null; + if (url == null) return null; final axis = _readAxis(url); if (axis == null) return null; final cue = selectSpriteThumbnailCue(axis.cues, position); if (cue == null) return null; - final cached = _readSprite(cue.spriteFile); + final cached = _readSprite( + axis.resolveSpriteUri(cue.spriteFile).toString(), + ); if (cached == null) return null; final sourceRect = _scaledSourceRect(cached, cue.sourceRect); if (sourceRect == null) return null; @@ -206,13 +209,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier super.dispose(); } - void _activateAxis(String url) { - if (_activeAxisUrl == url) return; - _activeAxisUrl = url; - _axisGeneration++; - _clearSprites(); - } - Future _loadAxis(String url) { final cached = _readAxis(url); if (cached != null) return Future.value(cached); @@ -239,7 +235,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier } on SpriteThumbnailForbiddenException { if (_disposed) return SpriteThumbnailAxis.empty(url); _axisCache.remove(url); - if (_activeAxisUrl == url) _clearSprites(); } catch (_) { break; } @@ -293,23 +288,23 @@ class SpriteThumbnailAxisService extends ChangeNotifier SpriteThumbnailAxis axis, SpriteThumbnailCue cue, ) { - final cached = _readSprite(cue.spriteFile); + final spriteUrl = axis.resolveSpriteUri(cue.spriteFile).toString(); + final cached = _readSprite(spriteUrl); if (cached != null) return Future.value(cached); - if (_failedSprites.contains(cue.spriteFile)) return Future.value(); - final existing = _spriteRequests[cue.spriteFile]; + if (_failedSprites.contains(spriteUrl)) return Future.value(); + final existing = _spriteRequests[spriteUrl]; if (existing != null) return existing; - final generation = _axisGeneration; - final request = _loadSprite(axis, cue, generation); - _spriteRequests[cue.spriteFile] = request; + final request = _loadSprite(axis, cue, spriteUrl); + _spriteRequests[spriteUrl] = request; return request.whenComplete(() { - _spriteRequests.remove(cue.spriteFile); + _spriteRequests.remove(spriteUrl); }); } Future<_CachedSprite?> _loadSprite( SpriteThumbnailAxis axis, SpriteThumbnailCue cue, - int generation, + String spriteUrl, ) async { SpriteThumbnailDecodedImage? decoded; try { @@ -318,16 +313,16 @@ class SpriteThumbnailAxisService extends ChangeNotifier decoded = null; } if (decoded == null) { - _failedSprites.add(cue.spriteFile); + _failedSprites.add(spriteUrl); if (!_disposed) notifyListeners(); return null; } - if (_disposed || generation != _axisGeneration) { + if (_disposed) { decoded.image.dispose(); return null; } final cached = _CachedSprite(decoded); - _putSprite(cue.spriteFile, cached); + _putSprite(spriteUrl, cached); notifyListeners(); return cached; } @@ -342,7 +337,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier void _putSprite(String key, _CachedSprite value) { _spriteCache.remove(key)?.decoded.image.dispose(); _spriteCache[key] = value; - while (_spriteCache.length > spriteImageCacheCapacity) { + while (_spriteCache.length > spriteCacheCapacity) { final oldest = _spriteCache.remove(_spriteCache.keys.first); oldest?.decoded.image.dispose(); } @@ -378,8 +373,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier } void _clearAll() { - _axisGeneration++; - _activeAxisUrl = null; _axisCache.clear(); _axisRequests.clear(); _clearSprites(); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index fcc6ddb..90958a2 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -146,7 +146,9 @@ class _PlayerPageState extends State _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); - _spriteAxisService = SpriteThumbnailAxisService(); + _spriteAxisService = SpriteThumbnailAxisService( + spriteCacheCapacity: 8, + )..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), @@ -266,7 +268,9 @@ class _PlayerPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); - _spriteAxisService.dispose(); + _spriteAxisService + ..removeListener(_handleSpriteFrameChanged) + ..dispose(); _thumbnailService.dispose(); logic.dispose(); super.dispose(); @@ -369,6 +373,30 @@ class _PlayerPageState extends State _chromeVisible = true; } + void _handleSpriteFrameChanged() { + if (mounted) setState(() {}); + } + + void _prewarmAdjacentThumbnails() { + final current = logic.session.currentIndex; + final indices = [ + current, + logic.previousVisibleIndex(current), + logic.nextVisibleIndex(current), + ]; + for (final index in indices) { + if (index == null || index < 0 || index >= logic.sessionEpisodes.length) { + continue; + } + final thumbnailsUrl = logic.sessionEpisodes[index].thumbnailsUrl; + if (thumbnailsUrl != null) { + unawaited( + _spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero), + ); + } + } + } + void _restoreFeedChrome() { if (!mounted) return; setState(() { @@ -451,6 +479,7 @@ class _PlayerPageState extends State final content = AnimatedBuilder( animation: logic.session, builder: (context, _) { + _prewarmAdjacentThumbnails(); final pages = NotificationListener( onNotification: _handlePageScroll, child: PageView.builder( @@ -590,6 +619,15 @@ class _PlayerPageState extends State final videoFit = theater ? BoxFit.contain : BoxFit.cover; final preparedFrameAvailable = selected && logic.session.reusedWarmAt(index); + final thumbnailsUrl = episode.thumbnailsUrl; + final openingFrameReady = + thumbnailsUrl != null && + _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; + if (thumbnailsUrl != null) { + unawaited( + _spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero), + ); + } final locked = logic.session.isLocked || episode.locked; if (selected && videoPort != null && @@ -617,9 +655,10 @@ class _PlayerPageState extends State theater: theater, holdOpeningUntilFirstFrame: logic.session.userWantsPlay, preparedFrameAvailable: preparedFrameAvailable, + openingFrameReady: openingFrameReady, openingFrame: playerOpeningFrame( - episode.thumbnailsUrl, - posterUrl: drama.posterUrl, + thumbnailsUrl, + spriteSource: _spriteAxisService, fit: videoFit, ), ), diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 338004d..467cb53 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -217,7 +217,6 @@ class _HomeFeedOverlayState extends State { spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.episode.thumbnailsUrl, episodeId: widget.episode.id, - posterUrl: widget.drama.posterUrl, onScrubStart: _startScrub, onScrubEnd: _endScrub, onSeek: widget.onSeek, diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index b6d42f3..8934d39 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -23,7 +23,6 @@ class _FeedProgressBar extends StatefulWidget { this.spriteAxisService, this.thumbnailsUrl, this.episodeId, - this.posterUrl = '', this.onScrubStart, this.onScrubEnd, this.onSeek, @@ -38,7 +37,6 @@ class _FeedProgressBar extends StatefulWidget { final SpriteThumbnailAxisSource? spriteAxisService; final String? thumbnailsUrl; final String? episodeId; - final String posterUrl; final VoidCallback? onScrubStart; final VoidCallback? onScrubEnd; final ValueChanged? onSeek; @@ -166,7 +164,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, - posterUrl: widget.posterUrl, position: previewPosition, duration: video!.duration, ), diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index 4596643..529f7a4 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -1,199 +1,97 @@ // Author: fruit.com.cn import 'dart:async'; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; -import '../../../core/config/app_config.dart'; -import '../../../core/data/services/sprite_thumbnail_asset_loader.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/widgets/remote_image.dart'; import 'scrub_preview_card.dart'; -/// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。 +/// 当前集雪碧图首帧在视频纹理未就绪时占位;空地址交还纯黑底。 Widget? playerOpeningFrame( String? thumbnailsUrl, { - String? posterUrl, + required SpriteThumbnailAxisSource spriteSource, BoxFit fit = BoxFit.cover, }) { final url = thumbnailsUrl?.trim() ?? ''; - final poster = posterUrl?.trim() ?? ''; - if (url.isEmpty && poster.isEmpty) return null; + if (url.isEmpty) return null; return PlayerOpeningFrame( - thumbnailsUrl: url.isEmpty ? null : url, - posterUrl: poster.isEmpty ? null : poster, + thumbnailsUrl: url, + spriteSource: spriteSource, fit: fit, ); } -/// 视频解码前依次使用封面和雪碧图首帧,最终由父层黑色兜底。 +/// 与视频共用全屏父约束和 BoxFit,切换时保持相同可见区域。 class PlayerOpeningFrame extends StatefulWidget { const PlayerOpeningFrame({ super.key, - this.thumbnailsUrl, - this.posterUrl, + required this.thumbnailsUrl, + required this.spriteSource, this.fit = BoxFit.cover, - this.loader, - this.cache, }); - final String? thumbnailsUrl; - final String? posterUrl; + /// 当前视频雪碧图 WebVTT 地址。 + final String thumbnailsUrl; + + /// 进度条拖动预览共用的雪碧图时间轴与解码缓存。 + final SpriteThumbnailAxisSource spriteSource; + + /// 必须与同页视频层使用相同适配规则。 final BoxFit fit; - final SpriteThumbnailAssetLoader? loader; - final OpeningFrameCache? cache; @override State createState() => _PlayerOpeningFrameState(); } class _PlayerOpeningFrameState extends State { - SpriteThumbnailFrame? _frame; - int _serial = 0; - @override void initState() { super.initState(); - unawaited(_load()); + widget.spriteSource.addListener(_handleSpriteChanged); + unawaited( + widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), + ); } @override void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.spriteSource != widget.spriteSource) { + oldWidget.spriteSource.removeListener(_handleSpriteChanged); + widget.spriteSource.addListener(_handleSpriteChanged); + } if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { - _frame = null; - unawaited(_load()); + unawaited( + widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), + ); } - } - - Future _load() async { - final serial = ++_serial; - final thumbnailsUrl = widget.thumbnailsUrl; - if (thumbnailsUrl == null) return; - final frame = await (widget.cache ?? OpeningFrameCache.shared).load( - thumbnailsUrl, - loader: widget.loader, - ); - if (!mounted || serial != _serial) return; - setState(() => _frame = frame); } @override - Widget build(BuildContext context) { - final frame = _frame; - final posterUrl = widget.posterUrl; - return Stack( - fit: StackFit.expand, - children: [ - const ColoredBox(color: AppColors.mediaBlack), - if (posterUrl != null) - RemoteImage( - posterUrl, - key: const Key('player-opening-poster'), - fit: widget.fit, - fallbackColor: AppColors.mediaBlack, - ), - if (frame != null) - SpriteCropPreview( - key: const Key('player-opening-sprite'), - image: frame.image, - sourceRect: frame.sourceRect, - fit: widget.fit, - ), - ], - ); - } -} - -/// 相邻页共用的雪碧图首帧缓存,避免每页重复拉 VTT。 -class OpeningFrameCache { - OpeningFrameCache({this.capacity = 6}); - - static final OpeningFrameCache shared = OpeningFrameCache(); - - final int capacity; - final Map _frames = {}; - final Map> _loads = {}; - - /// 取 [thumbnailsUrl] 对应的第一帧;失败返回 null。 - Future load( - String? thumbnailsUrl, { - SpriteThumbnailAssetLoader? loader, - }) { - final url = _normalize(thumbnailsUrl); - if (url == null) return Future.value(); - final cached = _frames[url]; - if (cached != null) return Future.value(cached); - return _loads.putIfAbsent(url, () => _fetch(url, loader)); - } - - Future _fetch( - String url, - SpriteThumbnailAssetLoader? loader, - ) async { - try { - final asset = loader ?? DioSpriteThumbnailAssetLoader(); - final text = await asset.loadVtt(Uri.parse(url)); - if (text == null || text.isEmpty) return null; - final axis = parseSpriteThumbnailVtt(text, vttUrl: url); - final cue = axis.cues.isEmpty ? null : axis.cues.first; - if (cue == null) return null; - final decoded = await asset.loadSprite( - axis.resolveSpriteUri(cue.spriteFile), - ); - if (decoded == null) return null; - final frame = SpriteThumbnailFrame( - image: decoded.image, - sourceRect: _scaledRect(decoded, cue.sourceRect), - spriteUrl: axis.resolveSpriteUri(cue.spriteFile).toString(), - cue: cue, - ); - _store(url, frame); - return frame; - } catch (_) { - return null; - } finally { - _loads.remove(url); - } - } - - void _store(String url, SpriteThumbnailFrame frame) { - _frames.remove(url); - _frames[url] = frame; - while (_frames.length > capacity) { - final oldest = _frames.keys.first; - _frames.remove(oldest)?.image.dispose(); - } - } - - ui.Rect _scaledRect(SpriteThumbnailDecodedImage decoded, ui.Rect source) { - if (decoded.originalWidth <= 0 || decoded.originalHeight <= 0) { - return source; - } - final scaleX = decoded.image.width / decoded.originalWidth; - final scaleY = decoded.image.height / decoded.originalHeight; - return ui.Rect.fromLTWH( - source.left * scaleX, - source.top * scaleY, - source.width * scaleX, - source.height * scaleY, - ); - } - - String? _normalize(String? raw) { - final value = AppConfig.mediaUrl(raw?.trim() ?? ''); - if (value.isEmpty) return null; - return value; - } - - /// 测试收尾时清空缓存。 - void clear() { - for (final frame in _frames.values) { - frame.image.dispose(); - } - _frames.clear(); - _loads.clear(); + void dispose() { + widget.spriteSource.removeListener(_handleSpriteChanged); + super.dispose(); } + + void _handleSpriteChanged() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: AppColors.mediaBlack), + if (widget.spriteSource.cachedFrameAt(widget.thumbnailsUrl, Duration.zero) + case final frame?) + SpriteCropPreview( + key: const Key('player-opening-sprite'), + image: frame.image, + sourceRect: frame.sourceRect, + fit: widget.fit, + ), + ], + ); } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index c9612b9..0a89365 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -303,7 +303,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { spriteAxisService: data.spriteAxisService, thumbnailsUrl: data.episode.thumbnailsUrl, episodeId: data.episode.id, - posterUrl: data.drama.posterUrl, inactiveTrackColor: AppColors.inactiveTrack, onScrubStart: actions.onScrubStart, onScrubEnd: actions.onScrubEnd, diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index eb23e61..9392121 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -18,6 +18,7 @@ class PlayerVideoSurface extends StatefulWidget { required this.theater, this.handoff, this.openingFrame, + this.openingFrameReady = false, this.holdOpeningUntilFirstFrame = false, this.preparedFrameAvailable = false, }); @@ -28,6 +29,9 @@ class PlayerVideoSurface extends StatefulWidget { /// 解码出第一帧之前垫上的视频首帧,没有则为空。 final Widget? openingFrame; + /// 当前集雪碧图 t=0 是否已在共享缓存中可用。 + final bool openingFrameReady; + /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 final bool holdOpeningUntilFirstFrame; @@ -73,10 +77,10 @@ class _PlayerVideoSurfaceState extends State { final controller = widget.controller; final handoff = _liveHandoff(controller); final firstFrameSeen = _trackFrameState(controller, value); - final firstFrameReady = - widget.preparedFrameAvailable || firstFrameSeen; + final firstFrameReady = widget.preparedFrameAvailable || firstFrameSeen; final textureReady = _shouldReveal(value, handoff != null); - final reveal = textureReady && + final reveal = + textureReady && (!widget.holdOpeningUntilFirstFrame || widget.preparedFrameAvailable || firstFrameReady || @@ -86,13 +90,7 @@ class _PlayerVideoSurfaceState extends State { final showOpening = widget.openingFrame != null && handoff == null && !reveal; _logStateChange(value); - _logLayerState( - value, - handoff, - reveal, - firstFrameReady, - showOpening, - ); + _logLayerState(value, handoff, reveal, firstFrameReady, showOpening); return Stack( fit: StackFit.expand, children: [ @@ -203,10 +201,20 @@ class _PlayerVideoSurfaceState extends State { : !firstFrameReady && widget.holdOpeningUntilFirstFrame ? 'awaiting-first-frame' : 'reveal'; + final fallback = handoff != null + ? 'handoff' + : showOpening + ? widget.openingFrameReady + ? 'thumbnails-first-frame' + : 'black' + : reveal + ? 'video' + : 'black'; final signature = 'warm=${widget.preparedFrameAvailable ? 1 : 0} ' 'frame=${firstFrameReady ? 1 : 0} ' - 'cover=${showOpening ? 1 : 0} ' + 'thumbnails=${widget.openingFrameReady ? 'ready' : 'pending'} ' + 'fallback=$fallback ' 'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} ' 'init=${value?.isInitialized == true ? 1 : 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart index 65eb2dc..b77a696 100644 --- a/lib/pages/player/widgets/scrub_preview_card.dart +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -9,7 +9,6 @@ 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'; -import '../../../core/widgets/remote_image.dart'; const double scrubPreviewWidth = 96; const double scrubPreviewSafeMargin = 12; @@ -36,7 +35,6 @@ class ScrubPreviewCard extends StatefulWidget { super.key, required this.service, required this.episodeId, - required this.posterUrl, required this.position, required this.duration, this.spriteAxisService, @@ -47,7 +45,6 @@ class ScrubPreviewCard extends StatefulWidget { final SpriteThumbnailAxisSource? spriteAxisService; final String? thumbnailsUrl; final String episodeId; - final String posterUrl; final Duration position; final Duration duration; @@ -207,11 +204,9 @@ class _ScrubPreviewCardState extends State { child: Stack( fit: StackFit.expand, children: [ - RemoteImage( - widget.posterUrl, - key: const Key('scrub-preview-poster'), - fit: BoxFit.cover, - fallbackColor: AppColors.bar, + const ColoredBox( + key: Key('scrub-preview-background'), + color: AppColors.bar, ), if (stage == ScrubPreviewStage.sprite && spriteFrame != null) SpriteCropPreview( @@ -265,11 +260,7 @@ class SpriteCropPreview extends StatelessWidget { @override Widget build(BuildContext context) => CustomPaint( - painter: _SpriteCropPainter( - image: image, - sourceRect: sourceRect, - fit: fit, - ), + painter: _SpriteCropPainter(image: image, sourceRect: sourceRect, fit: fit), ); } diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 0f91a2d..f0bbf32 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -41,7 +41,6 @@ class SeekProgressBar extends StatefulWidget { this.thumbnailService, this.spriteAxisService, this.episodeId, - this.posterUrl = '', this.thumbnailsUrl, }); @@ -57,7 +56,6 @@ class SeekProgressBar extends StatefulWidget { final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; final String? episodeId; - final String posterUrl; final String? thumbnailsUrl; @override @@ -351,7 +349,6 @@ class _SeekProgressBarState extends State { spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, - posterUrl: widget.posterUrl, position: previewPosition, duration: video.duration, ), diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 1571fda..f584ef6 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -249,9 +249,7 @@ void main() { }, ); - testWidgets('should_show_frame_card_with_cover_then_hide_on_release', ( - tester, - ) async { + testWidgets('should_show_frame_card_then_hide_on_release', (tester) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), @@ -272,7 +270,6 @@ void main() { valueListenable: video, semanticsLabel: 'Seek video', episodeId: 'episode-1', - posterUrl: '', thumbnailService: service, onSeek: (_) async {}, ), @@ -289,7 +286,7 @@ void main() { await tester.pump(); expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget); - expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); expect(find.textContaining('/ 00:12'), findsOneWidget); expect( diff --git a/test/playback/sprite_thumbnail_axis_test.dart b/test/playback/sprite_thumbnail_axis_test.dart index 663225b..4e45521 100644 --- a/test/playback/sprite_thumbnail_axis_test.dart +++ b/test/playback/sprite_thumbnail_axis_test.dart @@ -114,6 +114,32 @@ void main() { expect(service.hasAxis('https://cdn.test/thumbnails.vtt'), isTrue); }); + testWidgets('should_keep_zero_frames_ready_across_neighbor_episodes', ( + tester, + ) async { + final loader = _FakeSpriteLoader(vtt: _spriteVtt); + final service = SpriteThumbnailAxisService( + loader: loader, + spriteCacheCapacity: 6, + ); + addTearDown(service.dispose); + const current = 'https://cdn.test/episode-1/thumbnails.vtt'; + const previous = 'https://cdn.test/episode-0/thumbnails.vtt'; + const next = 'https://cdn.test/episode-2/thumbnails.vtt'; + + await Future.wait([ + service.prepareAt(previous, Duration.zero), + service.prepareAt(current, Duration.zero), + service.prepareAt(next, Duration.zero), + ]); + + for (final url in [previous, current, next]) { + expect(service.hasAxis(url), isTrue); + expect(service.cachedFrameAt(url, Duration.zero), isNotNull); + } + expect(loader.spriteCalls, hasLength(6)); + }); + testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async { var now = DateTime(2026, 10, 22); final loader = _FakeSpriteLoader(vtt: _spriteVtt); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index 139e9fe..294e2d2 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -5,8 +5,9 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart'; -import 'package:fruitshorts/core/widgets/remote_image.dart'; +import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart'; import 'package:fruitshorts/pages/player/widgets/player_opening_frame.dart'; +import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart'; const String _spriteVtt = ''' WEBVTT @@ -16,49 +17,46 @@ sprite_001.jpg#xywh=0,0,160,90 '''; void main() { - test('should_return_no_opening_frame_when_thumbnails_url_is_empty', () { - expect(playerOpeningFrame(null, posterUrl: ' '), isNull); - expect(playerOpeningFrame(' '), isNull); + testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', ( + tester, + ) async { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); + + expect(playerOpeningFrame(null, spriteSource: service), isNull); + expect(playerOpeningFrame(' ', spriteSource: service), isNull); }); - test('should_keep_opening_frame_when_only_poster_is_available', () { - expect( - playerOpeningFrame(null, posterUrl: 'https://cdn.test/poster.jpg'), - isA(), - ); - }); - - testWidgets('should_load_and_reuse_first_sprite_frame', (tester) async { + testWidgets('should_render_prewarmed_zero_frame_from_shared_cache', ( + tester, + ) async { final loader = _FakeSpriteLoader(); - final cache = OpeningFrameCache(); - addTearDown(cache.clear); + final service = SpriteThumbnailAxisService(loader: loader); + addTearDown(service.dispose); + const url = 'https://cdn.test/thumbnails.vtt'; + await service.prepareAt(url, Duration.zero); - final first = await cache.load( - 'https://cdn.test/thumbnails.vtt', - loader: loader, - ); - final second = await cache.load( - 'https://cdn.test/thumbnails.vtt', - loader: loader, + await tester.pumpWidget( + MaterialApp( + home: PlayerOpeningFrame(thumbnailsUrl: url, spriteSource: service), + ), ); + await tester.pump(); - expect(first, isNotNull); - expect(second, same(first)); + expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); expect(loader.vttCalls, 1); expect(loader.spriteCalls, ['sprite_001.jpg']); }); - testWidgets('should_render_decoded_opening_frame', (tester) async { - final loader = _FakeSpriteLoader(); - final cache = OpeningFrameCache(); - addTearDown(cache.clear); + testWidgets('should_load_sprite_when_not_prewarmed', (tester) async { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); await tester.pumpWidget( MaterialApp( home: PlayerOpeningFrame( thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', - loader: loader, - cache: cache, + spriteSource: service, ), ), ); @@ -67,80 +65,26 @@ void main() { expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); }); - testWidgets('should_show_poster_while_sprite_is_loading', (tester) async { - final cache = OpeningFrameCache(); - addTearDown(cache.clear); + testWidgets('should_use_same_fit_for_sprite_and_video', (tester) async { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); + const url = 'https://cdn.test/thumbnails.vtt'; + await service.prepareAt(url, Duration.zero); await tester.pumpWidget( MaterialApp( home: PlayerOpeningFrame( - thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', - posterUrl: 'https://cdn.test/poster.jpg', - loader: _FakeSpriteLoader(), - cache: cache, + thumbnailsUrl: url, + spriteSource: service, + fit: BoxFit.contain, ), ), ); - expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); - }); - - testWidgets('should_use_surface_fit_without_extra_frame_constraint', ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp( - home: PlayerOpeningFrame( - posterUrl: 'https://cdn.test/poster.jpg', - ), - ), + final preview = tester.widget( + find.byKey(const Key('player-opening-sprite')), ); - - final poster = tester.widget( - find.byKey(const Key('player-opening-poster')), - ); - - expect(poster.fit, BoxFit.cover); - expect(find.byType(AspectRatio), findsNothing); - }); - - test('should_forward_video_fit_to_opening_frame', () { - final frame = playerOpeningFrame( - null, - posterUrl: 'https://cdn.test/poster.jpg', - fit: BoxFit.contain, - ); - - expect(frame, isA()); - expect((frame! as PlayerOpeningFrame).fit, BoxFit.contain); - }); - - testWidgets('should_reuse_decoded_sprite_when_parent_rebuilds', ( - tester, - ) async { - final loader = _FakeSpriteLoader(); - final cache = OpeningFrameCache(); - addTearDown(cache.clear); - Widget build() => MaterialApp( - home: PlayerOpeningFrame( - thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', - posterUrl: 'https://cdn.test/poster.jpg', - loader: loader, - cache: cache, - ), - ); - - await tester.pumpWidget(build()); - await tester.pumpAndSettle(); - expect(loader.vttCalls, 1); - expect(loader.spriteCalls, hasLength(1)); - - await tester.pumpWidget(build()); - await tester.pumpAndSettle(); - - expect(loader.vttCalls, 1); - expect(loader.spriteCalls, hasLength(1)); - expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); + expect(preview.fit, BoxFit.contain); }); } diff --git a/test/player/scrub_preview_card_test.dart b/test/player/scrub_preview_card_test.dart index edb7d62..116902b 100644 --- a/test/player/scrub_preview_card_test.dart +++ b/test/player/scrub_preview_card_test.dart @@ -67,13 +67,11 @@ void main() { await tester.pump(); expect(loader.calls, 1); - expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); }); - testWidgets('should_keep_poster_when_axis_and_local_frame_both_fail', ( - tester, - ) async { + testWidgets('should_keep_background_when_frames_both_fail', (tester) async { final loader = _CountingThumbnailLoader(); final localService = _localService(loader); final axis = _FakeAxisSource(axisReady: true); @@ -90,7 +88,7 @@ void main() { await tester.pump(); expect(loader.calls, 1); - expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); }); @@ -109,7 +107,6 @@ Widget _card({ spriteAxisService: axis, thumbnailsUrl: thumbnailsUrl, episodeId: 'episode-1', - posterUrl: '', position: const Duration(seconds: 1), duration: const Duration(seconds: 10), ),