From 48b4e67d3497d86b70f440ffbd2e125bb155ed81 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:12:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E9=A2=84=E7=83=AD=E7=9B=B4?= =?UTF-8?q?=E5=87=BA=E5=B9=B6=E4=B8=8E=E8=A7=86=E9=A2=91=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/services/playback_session.dart | 2 + .../services/playback_session_controls.dart | 10 +++ lib/pages/player/player_view.dart | 5 ++ .../player/widgets/player_opening_frame.dart | 45 ++++--------- .../player/widgets/player_video_surface.dart | 41 ++++++++---- .../player/widgets/scrub_preview_card.dart | 21 ++++-- test/playback/playback_preload_test.dart | 2 + test/player/player_opening_frame_test.dart | 30 ++++----- test/player/player_video_surface_test.dart | 67 ++++++++----------- 9 files changed, 119 insertions(+), 104 deletions(-) diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 1a603a5..e3168fd 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -260,6 +260,7 @@ class PlaybackSession extends ChangeNotifier { _usableWarm(this, warmNext, index) ?? _usableWarm(this, warmPrevious, index); if (warm != null) { + warm.reusedWarm = true; target = _active = warm; _loading = !warm.prepared; _notify(); @@ -470,5 +471,6 @@ class _PlayerSlot { bool activated = false; bool playing = false; bool retiring = false; + bool reusedWarm = false; Object? failure; } diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart index b0f12f1..e0a4045 100644 --- a/lib/core/data/services/playback_session_controls.dart +++ b/lib/core/data/services/playback_session_controls.dart @@ -19,6 +19,16 @@ extension PlaybackSessionControls on PlaybackSession { return slot.player; } + /// 当前索引是否复用了已预热的播放器槽。 + bool reusedWarmAt(int index) { + for (final slot in [_active, _next, _previous]) { + if (slot != null && !slot.retiring && slot.index == index) { + return slot.reusedWarm; + } + } + return false; + } + /// 同步应用前台状态并恢复或暂停当前集。 void setAppResumed(bool value) { _appResumed = value; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 7ce9218..fcc6ddb 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -587,6 +587,9 @@ class _PlayerPageState extends State if (selected && videoReady) _logSwipeReveal(); final ready = videoReady || port != null; final theater = logic.pip || logic.pipPreparing; + final videoFit = theater ? BoxFit.contain : BoxFit.cover; + final preparedFrameAvailable = + selected && logic.session.reusedWarmAt(index); final locked = logic.session.isLocked || episode.locked; if (selected && videoPort != null && @@ -613,9 +616,11 @@ class _PlayerPageState extends State videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, holdOpeningUntilFirstFrame: logic.session.userWantsPlay, + preparedFrameAvailable: preparedFrameAvailable, openingFrame: playerOpeningFrame( episode.thumbnailsUrl, posterUrl: drama.posterUrl, + fit: videoFit, ), ), if (selected && diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index fb7022a..4596643 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -12,17 +12,19 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; import 'scrub_preview_card.dart'; -const double _posterAspectRatio = 3 / 4; -const double _posterBlurSigma = 18; - /// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。 -Widget? playerOpeningFrame(String? thumbnailsUrl, {String? posterUrl}) { +Widget? playerOpeningFrame( + String? thumbnailsUrl, { + String? posterUrl, + BoxFit fit = BoxFit.cover, +}) { final url = thumbnailsUrl?.trim() ?? ''; final poster = posterUrl?.trim() ?? ''; if (url.isEmpty && poster.isEmpty) return null; return PlayerOpeningFrame( thumbnailsUrl: url.isEmpty ? null : url, posterUrl: poster.isEmpty ? null : poster, + fit: fit, ); } @@ -32,12 +34,14 @@ class PlayerOpeningFrame extends StatefulWidget { super.key, this.thumbnailsUrl, this.posterUrl, + this.fit = BoxFit.cover, this.loader, this.cache, }); final String? thumbnailsUrl; final String? posterUrl; + final BoxFit fit; final SpriteThumbnailAssetLoader? loader; final OpeningFrameCache? cache; @@ -85,39 +89,18 @@ class _PlayerOpeningFrameState extends State { children: [ const ColoredBox(color: AppColors.mediaBlack), if (posterUrl != null) - Positioned.fill( - child: ImageFiltered( - key: const Key('player-opening-poster-blur'), - imageFilter: ui.ImageFilter.blur( - sigmaX: _posterBlurSigma, - sigmaY: _posterBlurSigma, - ), - child: RemoteImage( - posterUrl, - key: const Key('player-opening-poster-backdrop'), - fit: BoxFit.cover, - fallbackColor: AppColors.mediaBlack, - ), - ), - ), - if (posterUrl != null) - Center( - child: AspectRatio( - key: const Key('player-opening-poster-stage'), - aspectRatio: _posterAspectRatio, - child: RemoteImage( - posterUrl, - key: const Key('player-opening-poster'), - fit: BoxFit.cover, - fallbackColor: AppColors.mediaBlack, - ), - ), + 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, ), ], ); diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index a1a3618..eb23e61 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -6,7 +6,6 @@ import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; -const Duration _videoFadeDuration = Duration(milliseconds: 200); const double _errorHorizontal = 32; const int _errorMaxLines = 3; @@ -20,6 +19,7 @@ class PlayerVideoSurface extends StatefulWidget { this.handoff, this.openingFrame, this.holdOpeningUntilFirstFrame = false, + this.preparedFrameAvailable = false, }); final VideoPlayerController? controller; @@ -30,6 +30,9 @@ class PlayerVideoSurface extends StatefulWidget { /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 final bool holdOpeningUntilFirstFrame; + + /// 相邻集预热槽已持有可用画面,进入页面时不需要封面兜底。 + final bool preparedFrameAvailable; final String videoUrl; final bool theater; @@ -69,15 +72,27 @@ class _PlayerVideoSurfaceState extends State { Widget _frame(VideoPlayerValue? value) { final controller = widget.controller; final handoff = _liveHandoff(controller); - final firstFrameReady = _trackFrameState(controller, value); - final reveal = _shouldReveal(value, handoff != null) && + final firstFrameSeen = _trackFrameState(controller, value); + final firstFrameReady = + widget.preparedFrameAvailable || firstFrameSeen; + final textureReady = _shouldReveal(value, handoff != null); + final reveal = textureReady && (!widget.holdOpeningUntilFirstFrame || + widget.preparedFrameAvailable || firstFrameReady || _playbackStarted && value?.isPlaying == false && value?.isBuffering != true); + final showOpening = + widget.openingFrame != null && handoff == null && !reveal; _logStateChange(value); - _logLayerState(value, handoff, reveal, firstFrameReady); + _logLayerState( + value, + handoff, + reveal, + firstFrameReady, + showOpening, + ); return Stack( fit: StackFit.expand, children: [ @@ -85,19 +100,15 @@ class _PlayerVideoSurfaceState extends State { key: Key('player-fallback-background'), color: AppColors.mediaBlack, ), - if (widget.openingFrame != null) - AnimatedOpacity( + if (showOpening) + KeyedSubtree( key: const Key('player-opening-frame'), - opacity: reveal ? 0 : 1, - duration: handoff != null ? Duration.zero : _videoFadeDuration, - curve: Curves.easeOut, child: widget.openingFrame!, ), AnimatedOpacity( key: const Key('player-video-opacity'), opacity: reveal ? 1 : 0, - duration: handoff != null ? Duration.zero : _videoFadeDuration, - curve: Curves.easeOut, + duration: Duration.zero, child: reveal && controller != null ? _VideoLayer( controller: controller, @@ -180,20 +191,24 @@ class _PlayerVideoSurfaceState extends State { VideoPlayerController? handoff, bool reveal, bool firstFrameReady, + bool showOpening, ) { if (!kDebugMode) return; final reason = handoff != null ? 'handoff' : !_shouldReveal(value, handoff != null) ? 'uninitialized' + : widget.preparedFrameAvailable + ? 'warm-frame' : !firstFrameReady && widget.holdOpeningUntilFirstFrame ? 'awaiting-first-frame' : 'reveal'; final signature = - 'opening=${widget.openingFrame == null ? 0 : reveal ? 0 : 1} ' + 'warm=${widget.preparedFrameAvailable ? 1 : 0} ' + 'frame=${firstFrameReady ? 1 : 0} ' + 'cover=${showOpening ? 1 : 0} ' 'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} ' 'init=${value?.isInitialized == true ? 1 : 0} ' - 'frame=${firstFrameReady ? 1 : 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' 'buffering=${value?.isBuffering == true ? 1 : 0} ' 'reason=$reason'; diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart index 266d2db..65eb2dc 100644 --- a/lib/pages/player/widgets/scrub_preview_card.dart +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -256,26 +256,37 @@ class SpriteCropPreview extends StatelessWidget { super.key, required this.image, required this.sourceRect, + this.fit = BoxFit.cover, }); final ui.Image image; final ui.Rect sourceRect; + final BoxFit fit; @override Widget build(BuildContext context) => CustomPaint( - painter: _SpriteCropPainter(image: image, sourceRect: sourceRect), + painter: _SpriteCropPainter( + image: image, + sourceRect: sourceRect, + fit: fit, + ), ); } class _SpriteCropPainter extends CustomPainter { - const _SpriteCropPainter({required this.image, required this.sourceRect}); + const _SpriteCropPainter({ + required this.image, + required this.sourceRect, + required this.fit, + }); final ui.Image image; final ui.Rect sourceRect; + final BoxFit fit; @override void paint(Canvas canvas, Size size) { - final fitted = applyBoxFit(BoxFit.cover, sourceRect.size, size); + final fitted = applyBoxFit(fit, sourceRect.size, size); final sourceOffset = Alignment.center.inscribe( fitted.source, Offset.zero & sourceRect.size, @@ -302,7 +313,9 @@ class _SpriteCropPainter extends CustomPainter { @override bool shouldRepaint(covariant _SpriteCropPainter oldDelegate) => - oldDelegate.image != image || oldDelegate.sourceRect != sourceRect; + oldDelegate.image != image || + oldDelegate.sourceRect != sourceRect || + oldDelegate.fit != fit; } String _format(Duration value) { diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart index 15bdd23..bf0dbdc 100644 --- a/test/playback/playback_preload_test.dart +++ b/test/playback/playback_preload_test.dart @@ -161,11 +161,13 @@ void main() { await session.select(2, preloadIndex: 3, previousIndex: 1); await session.preloadSettled; expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-2')); + expect(session.reusedWarmAt(2), isTrue); await session.select(1, preloadIndex: 2, previousIndex: 0); await session.preloadSettled; expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-1')); + expect(session.reusedWarmAt(1), isTrue); expect( factory.players.firstWhere((player) => player.id == 'ep-1').playing, isTrue, diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index bc6d1d4..139e9fe 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -83,13 +83,9 @@ void main() { ); expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); - expect( - find.byKey(const Key('player-opening-poster-backdrop')), - findsOneWidget, - ); }); - testWidgets('should_constrain_poster_inside_native_vertical_frame', ( + testWidgets('should_use_surface_fit_without_extra_frame_constraint', ( tester, ) async { await tester.pumpWidget( @@ -100,23 +96,23 @@ void main() { ), ); - final stage = tester.widget( - find.byKey(const Key('player-opening-poster-stage')), - ); final poster = tester.widget( find.byKey(const Key('player-opening-poster')), ); - final backdrop = tester.widget( - find.byKey(const Key('player-opening-poster-backdrop')), + + 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(stage.aspectRatio, closeTo(3 / 4, 0.001)); - expect(poster.fit, BoxFit.cover); - expect(backdrop.fit, BoxFit.cover); - expect( - find.byKey(const Key('player-opening-poster-blur')), - findsOneWidget, - ); + expect(frame, isA()); + expect((frame! as PlayerOpeningFrame).fit, BoxFit.contain); }); testWidgets('should_reuse_decoded_sprite_when_parent_rebuilds', ( diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 5489223..9f70bee 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -30,7 +30,6 @@ void main() { expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); expect(find.byKey(const Key('fake-opening')), findsOneWidget); - expect(_openingOpacity(tester), 1); expect(_opacity(tester), 0); }); @@ -49,7 +48,7 @@ void main() { expect(find.byKey(const Key('player-opening-frame')), findsNothing); }); - testWidgets('should_cross_fade_video_over_opening_frame', ( + testWidgets('should_show_cover_until_first_playback_frame', ( tester, ) async { final controller = await _pumpSurface( @@ -69,7 +68,6 @@ void main() { await tester.pump(); expect(_opacity(tester), 0); - expect(_openingOpacity(tester), 1); expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); controller.value = const VideoPlayerValue( @@ -82,25 +80,36 @@ void main() { await tester.pump(); expect(_opacity(tester), 1); - expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); + expect(find.byKey(const Key('player-opening-frame')), findsNothing); expect( tester .widget( find.byKey(const Key('player-video-opacity')), ) .duration, - const Duration(milliseconds: 200), + Duration.zero, ); - await tester.pump(const Duration(milliseconds: 100)); - final halfway = _fadeValue(tester); - expect(halfway, greaterThan(0)); - expect(halfway, lessThan(1)); - final openingHalfway = _openingFadeValue(tester); - expect(openingHalfway, greaterThan(0)); - expect(openingHalfway, lessThan(1)); - await tester.pump(const Duration(milliseconds: 100)); - expect(_fadeValue(tester), 1); - expect(_openingFadeValue(tester), 0); + }); + + testWidgets('should_skip_cover_when_prewarmed_frame_is_available', ( + tester, + ) async { + await _pumpSurface( + tester, + openingFrame: const ColoredBox(color: Colors.red), + holdOpeningUntilFirstFrame: true, + preparedFrameAvailable: true, + value: const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration.zero, + isInitialized: true, + isPlaying: false, + ), + ); + + expect(find.byKey(const Key('player-opening-frame')), findsNothing); + expect(_opacity(tester), 1); }); testWidgets('should_show_video_error_above_retained_fallback', ( @@ -147,6 +156,7 @@ void main() { handoff: handoff, videoUrl: current.dataSource, theater: false, + openingFrame: const ColoredBox(color: Colors.red), ), ), ), @@ -155,6 +165,7 @@ void main() { await pump(previous); expect(find.byKey(const Key('player-video-handoff')), findsOneWidget); + expect(find.byKey(const Key('player-opening-frame')), findsNothing); expect(_opacity(tester), 1); await pump(null); @@ -168,6 +179,7 @@ Future _pumpSurface( VideoPlayerValue? value, Widget? openingFrame, bool holdOpeningUntilFirstFrame = false, + bool preparedFrameAvailable = false, }) async { final controller = VideoPlayerController.networkUrl( Uri.parse('http://192.168.31.187:8081/files/video.mp4'), @@ -183,6 +195,7 @@ Future _pumpSurface( theater: false, openingFrame: openingFrame, holdOpeningUntilFirstFrame: holdOpeningUntilFirstFrame, + preparedFrameAvailable: preparedFrameAvailable, ), ), ), @@ -202,27 +215,3 @@ double _opacity(WidgetTester tester) { .widget(find.byKey(const Key('player-video-opacity'))) .opacity; } - -double _openingOpacity(WidgetTester tester) { - return tester - .widget( - find.byKey(const Key('player-opening-frame')), - ) - .opacity; -} - -double _fadeValue(WidgetTester tester) { - final fade = find.descendant( - of: find.byKey(const Key('player-video-opacity')), - matching: find.byType(FadeTransition), - ); - return tester.widget(fade).opacity.value; -} - -double _openingFadeValue(WidgetTester tester) { - final fade = find.descendant( - of: find.byKey(const Key('player-opening-frame')), - matching: find.byType(FadeTransition), - ); - return tester.widget(fade).opacity.value; -}