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(
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<PlayerPage>
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<PlayerPage>
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,
@@ -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);