fix(player): 雪碧图缓存补齐槽位预取量,清屏按钮贴近进度条

三个渲染槽位各预取 2 张雪碧图,容量 3 装不下会导致「重建→重新下载解码→
notify→重建」的死循环,真机持续发烫掉帧;容量恢复为 8 并只在未命中时预取。
首页清屏态的退出按钮由距底 56 收到 24,贴着进度条上方。
This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-29 15:44:17 +08:00
1 parent 79463e65c2
commit d5bcda54d3
2 files changed
+38 -11

No files matched your search

+13 -11
View File
@@ -189,9 +189,10 @@ class _PlayerPageState extends State<PlayerPage>
_thumbnailService = PlaybackThumbnailService( _thumbnailService = PlaybackThumbnailService(
sourceResolver: _resolveThumbnailSource, sourceResolver: _resolveThumbnailSource,
); );
// 雪碧图每张解码后是整块位图,保留 3 部剧足够擦洗回退,8 部在低端机 // 容量必须盖住「同时预取的数量」:渲染栈有上一集/当前集/下一集三个槽位,
// 容易把图片缓存顶满甚至触发 OOM。 // 每槽预取当前 cue + 1 个相邻 cue,约 6 张。容量小于这个数时,每次重建都会
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 3) // 把别处刚解码的雪碧图挤掉,形成「重建→重新下载解码→notify→重建」的死循环。
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
..addListener(_handleSpriteFrameChanged); ..addListener(_handleSpriteFrameChanged);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
logic.start( logic.start(
@@ -1013,19 +1014,19 @@ class _PlayerPageState extends State<PlayerPage>
final thumbnailReady = final thumbnailReady =
thumbnailsUrl != null && thumbnailsUrl != null &&
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != 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)); unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
} }
if (selected && if (selected &&
!locked && !locked &&
videoPort != null && videoPort != null &&
videoPort.controller.value.isInitialized) { videoPort.controller.value.isInitialized) {
unawaited( final position = videoPort.controller.value.position;
_spriteAxisService.prepareAt( if (_spriteAxisService.cachedFrameAt(thumbnailsUrl, position) == null) {
thumbnailsUrl, unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, position));
videoPort.controller.value.position, }
),
);
} }
final pagePosition = indices.indexOf(index); final pagePosition = indices.indexOf(index);
return PlayerRenderSlotData( return PlayerRenderSlotData(
@@ -1286,7 +1287,8 @@ class _PlayerPageState extends State<PlayerPage>
if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing) if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing)
Positioned( Positioned(
right: 12, right: 12,
bottom: 56, // 进度条触控带 16pt + 8pt 间距,退出清屏按钮贴着进度条上方。
bottom: 24,
child: ExitClearScreenButton( child: ExitClearScreenButton(
key: const Key('home-feed-exit-clear-screen'), key: const Key('home-feed-exit-clear-screen'),
tooltip: l10n.exitClearScreen, tooltip: l10n.exitClearScreen,
@@ -159,6 +159,31 @@ void main() {
expect(loader.spriteCalls, hasLength(6)); 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 { testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async {
var now = DateTime(2026, 10, 22); var now = DateTime(2026, 10, 22);
final loader = _FakeSpriteLoader(vtt: _spriteVtt); final loader = _FakeSpriteLoader(vtt: _spriteVtt);