From a3a79f706828d4330c49e11588900b41e9761bf1 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:07:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E4=BF=AE=E6=AD=A3=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=E6=AF=94=E4=BE=8B=E4=B8=8E=E9=A6=96=E5=B8=A7=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=97=AA=E7=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 1 + .../player/widgets/player_opening_frame.dart | 35 ++++++++-- .../player/widgets/player_video_surface.dart | 68 ++++++++++++++++++- test/player/player_opening_frame_test.dart | 35 ++++++++++ test/player/player_video_surface_test.dart | 16 +++++ 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 7b07f04..7ce9218 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -612,6 +612,7 @@ class _PlayerPageState extends State handoff: handoffPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, + holdOpeningUntilFirstFrame: logic.session.userWantsPlay, openingFrame: playerOpeningFrame( episode.thumbnailsUrl, posterUrl: drama.posterUrl, diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index 594c456..fb7022a 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -12,6 +12,9 @@ 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}) { final url = thumbnailsUrl?.trim() ?? ''; @@ -82,11 +85,33 @@ class _PlayerOpeningFrameState extends State { children: [ const ColoredBox(color: AppColors.mediaBlack), if (posterUrl != null) - RemoteImage( - posterUrl, - key: const Key('player-opening-poster'), - fit: BoxFit.cover, - fallbackColor: AppColors.mediaBlack, + 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, + ), + ), ), if (frame != null) SpriteCropPreview( diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index d57551d..a1a3618 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -19,6 +19,7 @@ class PlayerVideoSurface extends StatefulWidget { required this.theater, this.handoff, this.openingFrame, + this.holdOpeningUntilFirstFrame = false, }); final VideoPlayerController? controller; @@ -26,6 +27,9 @@ class PlayerVideoSurface extends StatefulWidget { /// 解码出第一帧之前垫上的视频首帧,没有则为空。 final Widget? openingFrame; + + /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 + final bool holdOpeningUntilFirstFrame; final String videoUrl; final bool theater; @@ -35,11 +39,21 @@ class PlayerVideoSurface extends StatefulWidget { class _PlayerVideoSurfaceState extends State { String? _lastDebugState; + String? _lastLayerState; + VideoPlayerController? _firstFrameController; + bool _playbackStarted = false; + bool _firstFrameSeen = false; @override void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) _lastDebugState = null; + if (oldWidget.controller != widget.controller) { + _lastDebugState = null; + _lastLayerState = null; + _firstFrameController = null; + _playbackStarted = false; + _firstFrameSeen = false; + } } @override @@ -55,8 +69,15 @@ class _PlayerVideoSurfaceState extends State { Widget _frame(VideoPlayerValue? value) { final controller = widget.controller; final handoff = _liveHandoff(controller); - final reveal = _shouldReveal(value, handoff != null); + final firstFrameReady = _trackFrameState(controller, value); + final reveal = _shouldReveal(value, handoff != null) && + (!widget.holdOpeningUntilFirstFrame || + firstFrameReady || + _playbackStarted && + value?.isPlaying == false && + value?.isBuffering != true); _logStateChange(value); + _logLayerState(value, handoff, reveal, firstFrameReady); return Stack( fit: StackFit.expand, children: [ @@ -116,6 +137,22 @@ class _PlayerVideoSurfaceState extends State { return value.size.width > 0 && value.size.height > 0; } + bool _trackFrameState( + VideoPlayerController? controller, + VideoPlayerValue? value, + ) { + if (!identical(_firstFrameController, controller)) { + _firstFrameController = controller; + _playbackStarted = false; + _firstFrameSeen = false; + } + if (value == null) return false; + _playbackStarted = _playbackStarted || value.isPlaying; + _firstFrameSeen = + _firstFrameSeen || value.position > Duration.zero || value.isCompleted; + return _firstFrameSeen; + } + /// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。 VideoPlayerController? _liveHandoff(VideoPlayerController? controller) { final handoff = widget.handoff; @@ -138,6 +175,33 @@ class _PlayerVideoSurfaceState extends State { ); } + void _logLayerState( + VideoPlayerValue? value, + VideoPlayerController? handoff, + bool reveal, + bool firstFrameReady, + ) { + if (!kDebugMode) return; + final reason = handoff != null + ? 'handoff' + : !_shouldReveal(value, handoff != null) + ? 'uninitialized' + : !firstFrameReady && widget.holdOpeningUntilFirstFrame + ? 'awaiting-first-frame' + : 'reveal'; + final signature = + 'opening=${widget.openingFrame == null ? 0 : reveal ? 0 : 1} ' + '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'; + if (signature == _lastLayerState) return; + _lastLayerState = signature; + debugPrint('[player-layer] $signature'); + } + String _debugStatus(VideoPlayerValue? value) { if (value == null) return 'loading'; if (value.hasError) { diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index e65c4da..bc6d1d4 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -5,6 +5,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart'; +import 'package:fruitshorts/core/widgets/remote_image.dart'; import 'package:fruitshorts/pages/player/widgets/player_opening_frame.dart'; const String _spriteVtt = ''' @@ -82,6 +83,40 @@ 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', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: PlayerOpeningFrame( + posterUrl: 'https://cdn.test/poster.jpg', + ), + ), + ); + + 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(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, + ); }); 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 120e76a..5489223 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -55,9 +55,23 @@ void main() { final controller = await _pumpSurface( tester, openingFrame: const ColoredBox(color: Colors.red), + holdOpeningUntilFirstFrame: true, ); expect(_opacity(tester), 0); + controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration.zero, + isInitialized: true, + isPlaying: true, + ); + await tester.pump(); + + expect(_opacity(tester), 0); + expect(_openingOpacity(tester), 1); + expect(find.byKey(const Key('player-opening-frame')), findsOneWidget); + controller.value = const VideoPlayerValue( duration: Duration(minutes: 1), size: Size(1280, 720), @@ -153,6 +167,7 @@ Future _pumpSurface( WidgetTester tester, { VideoPlayerValue? value, Widget? openingFrame, + bool holdOpeningUntilFirstFrame = false, }) async { final controller = VideoPlayerController.networkUrl( Uri.parse('http://192.168.31.187:8081/files/video.mp4'), @@ -167,6 +182,7 @@ Future _pumpSurface( videoUrl: controller.dataSource, theater: false, openingFrame: openingFrame, + holdOpeningUntilFirstFrame: holdOpeningUntilFirstFrame, ), ), ),