From 80c962a95ce6f84e5cf4293d8efa0a9e80834a8b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 15:10:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E6=94=B6=E7=B4=A7=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8E=E5=BF=AB=E9=80=9F=E5=88=87=E9=9B=86=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=8D=A0=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/services/playback_session.dart | 2 + .../data/services/sprite_thumbnail_axis.dart | 6 +- lib/core/widgets/remote_image.dart | 75 ++++++++++++++++--- lib/pages/player/player_logic.dart | 34 +++++++++ lib/pages/player/player_view.dart | 24 +++++- .../player/widgets/player_opening_frame.dart | 26 +++++-- test/playback/sprite_thumbnail_axis_test.dart | 41 +++++++--- test/player/player_logic_test.dart | 36 +++++++++ test/player/player_opening_frame_test.dart | 32 ++++++++ test/player/remote_image_decode_test.dart | 35 +++++++++ 10 files changed, 281 insertions(+), 30 deletions(-) create mode 100644 test/player/remote_image_decode_test.dart diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index e3168fd..43727cb 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -81,6 +81,8 @@ class PlaybackSession extends ChangeNotifier { // Includes initialization and asynchronous disposal, until native release ends. int get livePlayerCount => _live.length; int? get preloadedIndex => _next?.prepared == true ? _next?.index : null; + int? get previousPreloadedIndex => + _previous?.prepared == true ? _previous?.index : null; Future get playbackSettled => _operations; Future get preloadSettled => _preloadTask; diff --git a/lib/core/data/services/sprite_thumbnail_axis.dart b/lib/core/data/services/sprite_thumbnail_axis.dart index e6f7080..b8a4270 100644 --- a/lib/core/data/services/sprite_thumbnail_axis.dart +++ b/lib/core/data/services/sprite_thumbnail_axis.dart @@ -9,7 +9,7 @@ import '../../config/app_config.dart'; import 'sprite_thumbnail_asset_loader.dart'; const Duration spriteVttCacheTtl = Duration(minutes: 10); -const int spriteImageCacheCapacity = 2; +const int spriteImageCacheCapacity = 1; /// 单个 VTT cue 对应的 sprite 裁剪区域。 class SpriteThumbnailCue { @@ -148,7 +148,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier final Set _failedSprites = {}; bool _disposed = false; - @visibleForTesting int get spriteCacheSize => _spriteCache.length; @visibleForTesting @@ -156,7 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier .map((url) => Uri.tryParse(url)?.pathSegments.lastOrNull ?? url) .toList(); - /// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。 + /// 播放器就绪时预取当前 cue;容量大于 1 时顺带预取相邻 sprite。 @override Future prepareAt(String? thumbnailsUrl, Duration position) async { final url = _normalizeUrl(thumbnailsUrl); @@ -269,6 +268,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier if (current == null) return const []; final index = axis.cues.indexOf(current); final candidates = [current]; + if (spriteCacheCapacity <= 1) return candidates; for (final direction in const [1, -1]) { for (var step = 1; step < axis.cues.length; step++) { final neighborIndex = index + direction * step; diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index 967c8e1..4462959 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -5,6 +5,24 @@ import 'package:flutter/material.dart'; import '../config/app_config.dart'; import '../theme/app_colors.dart'; +const int _minImageDecodePixels = 96; +const int _maxImageDecodePixels = 2048; + +/// 将布局逻辑尺寸换算成有限像素尺寸,兼顾小头像和全屏首帧。 +int resolveImageDecodePixels(double? logicalExtent, double devicePixelRatio) { + final ratio = devicePixelRatio.isFinite && devicePixelRatio > 0 + ? devicePixelRatio + : 1.0; + final extent = + logicalExtent != null && logicalExtent.isFinite && logicalExtent > 0 + ? logicalExtent + : 120.0; + return (extent * ratio) + .ceil() + .clamp(_minImageDecodePixels, _maxImageDecodePixels) + .toInt(); +} + /// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。 class RemoteImage extends StatelessWidget { const RemoteImage( @@ -17,6 +35,8 @@ class RemoteImage extends StatelessWidget { this.fallbackIconKey, this.fallbackAspectRatio, this.fallbackColor = AppColors.raised, + this.decodeWidth, + this.decodeHeight, }); final String path; @@ -27,21 +47,56 @@ class RemoteImage extends StatelessWidget { final Key? fallbackIconKey; final double? fallbackAspectRatio; final Color fallbackColor; + final int? decodeWidth; + final int? decodeHeight; @override Widget build(BuildContext context) { final url = AppConfig.mediaUrl(path); if (url.isEmpty) return _fallback(brand: showFallbackIcon); - return Image.network( - url, - width: width, - height: height, - fit: fit, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon), - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) return child; - return _fallback(brand: showFallbackIcon); + return LayoutBuilder( + builder: (context, constraints) { + final pixelRatio = MediaQuery.devicePixelRatioOf(context); + final layoutWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : width; + final aspectRatio = + fallbackAspectRatio != null && + fallbackAspectRatio!.isFinite && + fallbackAspectRatio! > 0 + ? fallbackAspectRatio + : null; + final inferredHeight = constraints.hasBoundedHeight + ? constraints.maxHeight + : height ?? + (layoutWidth != null && aspectRatio != null + ? layoutWidth / aspectRatio + : null); + final cacheWidth = + decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio); + final cacheHeight = + decodeHeight ?? + (inferredHeight == null + ? null + : resolveImageDecodePixels(inferredHeight, pixelRatio)); + return Image.network( + url, + width: width, + height: height, + fit: fit, + cacheWidth: cacheWidth + .clamp(_minImageDecodePixels, _maxImageDecodePixels) + .toInt(), + cacheHeight: cacheHeight + ?.clamp(_minImageDecodePixels, _maxImageDecodePixels) + .toInt(), + gaplessPlayback: true, + errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon), + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return _fallback(brand: showFallbackIcon); + }, + ); }, ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index fa56e6f..b474a6b 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -93,6 +93,9 @@ class PlayerLogic extends ChangeNotifier { String selectedQuality = 'AUTO'; Duration? _qualitySeek; int pageRequest = 0; + int? _pendingSelectionIndex; + int? _pendingSelectionRevision; + Future? _selectionDrain; bool muted = false; double playbackSpeed = 1.0; bool stoppedAtFeedEnd = false; @@ -957,6 +960,37 @@ class PlayerLogic extends ChangeNotifier { ); } + /// 翻页入口使用 single-flight:执行中的切集不被重复排队,只保留最新目标。 + Future requestEpisodeSelection(int index, {int? revision}) { + if (_disposed || index < 0 || index >= sessionEpisodes.length) { + return Future.value(); + } + _pendingSelectionIndex = index; + _pendingSelectionRevision = revision; + return _selectionDrain ??= _drainEpisodeSelections(); + } + + Future _drainEpisodeSelections() async { + try { + while (!_disposed) { + final index = _pendingSelectionIndex; + if (index == null) break; + final revision = _pendingSelectionRevision; + _pendingSelectionIndex = null; + _pendingSelectionRevision = null; + await selectEpisode(index, revision: revision); + } + } finally { + _selectionDrain = null; + final index = _pendingSelectionIndex; + if (!_disposed && index != null) { + unawaited( + requestEpisodeSelection(index, revision: _pendingSelectionRevision), + ); + } + } + } + Future selectEpisode(int index, {int? revision}) async { final feedRevisionAtStart = revision ?? feedRevision; final request = ++pageRequest; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 8a001ce..4e8c146 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -353,6 +353,28 @@ class _PlayerPageState extends State ); } + Future _selectPage(int index) async { + await logic.requestEpisodeSelection(index); + if (!mounted || logic.session.currentIndex != index) return; + await logic.session.preloadSettled; + if (!mounted || logic.session.currentIndex != index) return; + _logResourceWatermark(); + } + + void _logResourceWatermark() { + if (!kDebugMode) return; + final imageCache = PaintingBinding.instance.imageCache; + debugPrint( + '[player-resources] ' + 'slots=${logic.session.livePlayerCount} ' + 'warmNext=${logic.session.preloadedIndex ?? '-'} ' + 'warmPrevious=${logic.session.previousPreloadedIndex ?? '-'} ' + 'sprites=${_spriteAxisService.spriteCacheSize} ' + 'imageCacheBytes=${imageCache.currentSizeBytes} ' + 'imageCacheCount=${imageCache.currentSize}', + ); + } + void _setScrubbing(bool value) { if (!mounted) return; if (_scrubbing == value) return; @@ -507,7 +529,7 @@ class _PlayerPageState extends State onPageChanged: (position) { final index = indices[position]; widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); - unawaited(logic.selectEpisode(index)); + unawaited(_selectPage(index)); if (position >= indices.length - 3) { final loadMore = widget.onLoadMore; if (loadMore != null) unawaited(loadMore()); diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index b685c64..2841056 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import '../../../core/config/app_config.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'; enum PlayerOpeningFallback { handoff, poster, thumbnails, black } @@ -149,12 +150,25 @@ class _PlayerOpeningFrameState extends State { children: [ const ColoredBox(color: AppColors.mediaBlack), if (_hasPoster) - Image.network( - AppConfig.mediaUrl(widget.posterUrl!.trim()), - key: const Key('player-opening-poster'), - fit: widget.fit, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _handlePosterError(), + LayoutBuilder( + builder: (context, constraints) { + final pixelRatio = MediaQuery.devicePixelRatioOf(context); + return Image.network( + AppConfig.mediaUrl(widget.posterUrl!.trim()), + key: const Key('player-opening-poster'), + fit: widget.fit, + cacheWidth: resolveImageDecodePixels( + constraints.maxWidth, + pixelRatio, + ), + cacheHeight: resolveImageDecodePixels( + constraints.maxHeight, + pixelRatio, + ), + gaplessPlayback: true, + errorBuilder: (_, _, _) => _handlePosterError(), + ); + }, ) else _spriteOrBlack(), diff --git a/test/playback/sprite_thumbnail_axis_test.dart b/test/playback/sprite_thumbnail_axis_test.dart index 90cba24..60cd045 100644 --- a/test/playback/sprite_thumbnail_axis_test.dart +++ b/test/playback/sprite_thumbnail_axis_test.dart @@ -75,7 +75,9 @@ void main() { expect(axis.cues, isEmpty); }); - testWidgets('should_keep_only_two_decoded_sprites_in_lru', (tester) async { + testWidgets('should_keep_one_sprite_and_reload_after_eviction', ( + tester, + ) async { final loader = _FakeSpriteLoader(vtt: _spriteVtt); final service = SpriteThumbnailAxisService( loader: loader, @@ -84,20 +86,37 @@ void main() { addTearDown(service.dispose); await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); - expect(service.spriteCacheSize, 2); - expect( - service.spriteCacheKeys, - containsAll(['sprite_001.jpg', 'sprite_002.jpg']), - ); - await service.prepareAt( 'https://cdn.test/thumbnails.vtt', const Duration(milliseconds: 2500), ); + await service.prepareAt( + 'https://cdn.test/thumbnails.vtt', + const Duration(milliseconds: 3500), + ); - expect(service.spriteCacheSize, 2); - expect(service.spriteCacheKeys, contains('sprite_002.jpg')); - expect(service.spriteCacheKeys, isNot(contains('sprite_001.jpg'))); + expect(service.spriteCacheSize, 1); + expect(service.spriteCacheKeys, ['sprite_003.jpg']); + expect(loader.images[0].debugDisposed, isTrue); + expect(loader.images[1].debugDisposed, isTrue); + expect(loader.images[2].debugDisposed, isFalse); + expect( + service.cachedFrameAt( + 'https://cdn.test/thumbnails.vtt', + const Duration(milliseconds: 3500), + ), + isNotNull, + ); + + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + expect(service.spriteCacheSize, 1); + expect(service.spriteCacheKeys, ['sprite_001.jpg']); + expect(loader.images[2].debugDisposed, isTrue); + expect(loader.images[3].debugDisposed, isFalse); + expect( + service.cachedFrameAt('https://cdn.test/thumbnails.vtt', Duration.zero), + isNotNull, + ); }); testWidgets('should_cache_vtt_and_retry_once_after_403', (tester) async { @@ -229,6 +248,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader { final List? vttResponses; int vttCalls = 0; final List spriteCalls = []; + final List images = []; @override Future loadVtt(Uri uri) async { @@ -244,6 +264,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader { Future loadSprite(Uri uri) async { spriteCalls.add(uri.pathSegments.last); final image = await _createImage(320, 180); + images.add(image); return SpriteThumbnailDecodedImage( image: image, originalWidth: 320, diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 3b7b661..aa7a3f2 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -422,6 +422,42 @@ void main() { await _drain(logic); }); + test('should_coalesce_rapid_page_selection_and_keep_pool_bounded', () async { + final factory = _RecordingFactory(); + final playback = FakePlaybackRepository(); + final logic = _logic(FakeWatchHistory(), playback: playback); + logic.attach( + PlayerPage( + drama: _drama('rapid', numbers: const [1, 2, 3, 4, 5, 6]), + initialIndex: 0, + ), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + await Future.wait([ + for (var index = 1; index <= 5; index++) + logic.requestEpisodeSelection(index), + ]); + await _drain(logic); + + expect(logic.session.currentIndex, 5); + expect(playback.requests, containsAll(['rapid-1', 'rapid-2', 'rapid-6'])); + expect(playback.requests, isNot(contains('rapid-3'))); + expect(playback.requests, isNot(contains('rapid-4'))); + expect(playback.requests, isNot(contains('rapid-5'))); + expect(logic.session.livePlayerCount, lessThanOrEqualTo(3)); + expect(factory.live, lessThanOrEqualTo(3)); + + await logic.requestEpisodeSelection(0); + await _drain(logic); + expect(logic.session.currentIndex, 0); + + logic.dispose(); + await _drain(logic); + }); + group('surface flags', () { test('should_set_immersive_when_picture_in_picture_callback_fires', () async { final factory = _RecordingFactory(); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index 587b0e4..c0a33c5 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -64,6 +64,38 @@ void main() { expect(loader.vttCalls, 0); }); + testWidgets('should_decode_fullscreen_poster_at_screen_width', ( + tester, + ) async { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(devicePixelRatio: 3), + child: Center( + child: SizedBox( + width: 390, + height: 844, + child: PlayerOpeningFrame( + posterUrl: 'https://cdn.test/poster.jpg', + thumbnailsUrl: '', + spriteSource: service, + ), + ), + ), + ), + ), + ); + + final image = tester.widget( + find.byKey(const Key('player-opening-poster')), + ); + final provider = image.image as ResizeImage; + expect(provider.width, 1170); + }); + testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', ( tester, ) async { diff --git a/test/player/remote_image_decode_test.dart b/test/player/remote_image_decode_test.dart new file mode 100644 index 0000000..0cd7de0 --- /dev/null +++ b/test/player/remote_image_decode_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/widgets/remote_image.dart'; + +void main() { + test('should_bound_layout_pixels_for_small_and_large_extents', () { + expect(resolveImageDecodePixels(12, 3), 96); + expect(resolveImageDecodePixels(120, 3), 360); + expect(resolveImageDecodePixels(4000, 3), 2048); + }); + + testWidgets('should_decode_grid_cover_at_layout_width_times_dpr', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(devicePixelRatio: 3), + child: Center( + child: SizedBox( + width: 120, + height: 160, + child: RemoteImage('/files/grid-cover.jpg'), + ), + ), + ), + ), + ); + + final image = tester.widget(find.byType(Image)); + final provider = image.image as ResizeImage; + expect(provider.width, 360); + expect(provider.height, 480); + }); +}