diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index d0ccad2..d3e88b0 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -189,9 +189,10 @@ class _PlayerPageState extends State _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); - // 雪碧图每张解码后是整块位图,保留 3 部剧足够擦洗回退,8 部在低端机 - // 容易把图片缓存顶满甚至触发 OOM。 - _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 3) + // 容量必须盖住「同时预取的数量」:渲染栈有上一集/当前集/下一集三个槽位, + // 每槽预取当前 cue + 1 个相邻 cue,约 6 张。容量小于这个数时,每次重建都会 + // 把别处刚解码的雪碧图挤掉,形成「重建→重新下载解码→notify→重建」的死循环。 + _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); logic.start( @@ -1013,19 +1014,19 @@ class _PlayerPageState extends State final thumbnailReady = thumbnailsUrl != null && _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; - if (!locked && !posterReady && thumbnailsUrl != null) { + // 只在没命中时预取:prepareAt 会触发下载解码并 notifyListeners -> 重建, + // 无条件调用会让每次重建都排一次异步任务。 + if (!locked && !posterReady && !thumbnailReady && thumbnailsUrl != null) { unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } if (selected && !locked && videoPort != null && videoPort.controller.value.isInitialized) { - unawaited( - _spriteAxisService.prepareAt( - thumbnailsUrl, - videoPort.controller.value.position, - ), - ); + final position = videoPort.controller.value.position; + if (_spriteAxisService.cachedFrameAt(thumbnailsUrl, position) == null) { + unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, position)); + } } final pagePosition = indices.indexOf(index); return PlayerRenderSlotData( @@ -1286,7 +1287,8 @@ class _PlayerPageState extends State if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing) Positioned( right: 12, - bottom: 56, + // 进度条触控带 16pt + 8pt 间距,退出清屏按钮贴着进度条上方。 + bottom: 24, child: ExitClearScreenButton( key: const Key('home-feed-exit-clear-screen'), tooltip: l10n.exitClearScreen, diff --git a/test/playback/sprite_thumbnail_axis_test.dart b/test/playback/sprite_thumbnail_axis_test.dart index 60cd045..dd61534 100644 --- a/test/playback/sprite_thumbnail_axis_test.dart +++ b/test/playback/sprite_thumbnail_axis_test.dart @@ -159,6 +159,31 @@ void main() { expect(loader.spriteCalls, hasLength(6)); }); + testWidgets('should_not_reload_three_slots_sprites_when_capacity_fits', ( + tester, + ) async { + final loader = _FakeSpriteLoader(vtt: _spriteVtt); + // 播放页三个槽位各预取 2 张,容量必须 >= 6,否则每轮重建都会重新下载解码。 + final service = SpriteThumbnailAxisService( + loader: loader, + spriteCacheCapacity: 8, + ); + addTearDown(service.dispose); + const urls = [ + 'https://cdn.test/episode-0/thumbnails.vtt', + 'https://cdn.test/episode-1/thumbnails.vtt', + 'https://cdn.test/episode-2/thumbnails.vtt', + ]; + + for (var round = 0; round < 3; round++) { + for (final url in urls) { + await service.prepareAt(url, Duration.zero); + } + } + + 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);