diff --git a/lib/pages/player/player_frame_capture.dart b/lib/pages/player/player_frame_capture.dart index bd83025..41e7b18 100644 --- a/lib/pages/player/player_frame_capture.dart +++ b/lib/pages/player/player_frame_capture.dart @@ -6,11 +6,56 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import '../../core/config/app_config.dart'; + +class PreparedPlayerFrame { + const PreparedPlayerFrame({this.bytes, this.provider}); + + final Uint8List? bytes; + final ImageProvider? provider; + + bool get isReady => bytes != null || provider != null; +} + class PlayerFrameCapture { const PlayerFrameCapture._(); static const int budgetMilliseconds = 32; + static Future captureAndPrecache({ + required BuildContext context, + required GlobalKey boundaryKey, + required String source, + String? fallbackPosterUrl, + }) async { + final bytes = await capture( + context: context, + boundaryKey: boundaryKey, + source: source, + ); + if (bytes != null) { + if (!context.mounted) return const PreparedPlayerFrame(); + final provider = MemoryImage(bytes); + if (await _precache(context, provider, source: source, kind: 'capture')) { + _log( + '[player-entry] phase=frame-precached source=$source kind=capture', + ); + return PreparedPlayerFrame(bytes: bytes, provider: provider); + } + } + + final poster = fallbackPosterUrl?.trim() ?? ''; + if (poster.isNotEmpty) { + if (!context.mounted) return const PreparedPlayerFrame(); + final provider = NetworkImage(AppConfig.mediaUrl(poster)); + if (await _precache(context, provider, source: source, kind: 'poster')) { + _log('[player-entry] phase=frame-precached source=$source kind=poster'); + return PreparedPlayerFrame(provider: provider); + } + } + return const PreparedPlayerFrame(); + } + static Future capture({ required BuildContext context, required GlobalKey boundaryKey, @@ -57,6 +102,32 @@ class PlayerFrameCapture { } } + static Future _precache( + BuildContext context, + ImageProvider provider, { + required String source, + required String kind, + }) async { + var failed = false; + try { + await precacheImage( + provider, + context, + onError: (_, _) { + failed = true; + }, + ); + } catch (_) { + failed = true; + } + if (failed) { + _log( + '[player-entry] phase=frame-precache-failed source=$source kind=$kind', + ); + } + return !failed; + } + static void _log(String message) { if (kDebugMode) debugPrint(message); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index f772ad1..a9db4d2 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -230,7 +230,6 @@ class PlayerLogic extends ChangeNotifier { cancelScrub(); await session.pauseForRouteHandoff(); _logFeedHandoff('paused'); - await saveCurrentProgress(); } /// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。 @@ -1230,27 +1229,38 @@ class PlayerLogic extends ChangeNotifier { Drama drama, int index, ) async { - final initialFrameBytes = await PlayerFrameCapture.capture( + final fallbackPosterUrl = index >= 0 && index < drama.episodes.length + ? drama.episodes[index].posterUrl + : null; + final preparedFrame = await PlayerFrameCapture.captureAndPrecache( context: context, boundaryKey: renderBoundaryKey, source: 'watch-all', + fallbackPosterUrl: fallbackPosterUrl, ); if (!context.mounted) return; await pauseForFullPlayer(); if (!context.mounted) return; - await Navigator.of(context).push( + final route = Navigator.of(context).push( FadePageRoute( - transitionDuration: const Duration(milliseconds: 180), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, builder: (_) => PlayerPage( drama: drama, initialIndex: index, wallet: wallet, downloads: downloads, - revealAfterFirstFrame: initialFrameBytes != null, - initialFrameBytes: initialFrameBytes, + revealAfterFirstFrame: preparedFrame.isReady, + initialFrameBytes: preparedFrame.bytes, + initialFrameProvider: preparedFrame.provider, ), ), ); + if (kDebugMode) { + debugPrint('[player-entry] phase=route-pushed source=watch-all ms=0'); + } + unawaited(saveCurrentProgress()); + await route; if (!isMounted()) return; await resumeAfterFullPlayer(); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index e59148f..f37bf2c 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -93,6 +93,7 @@ class PlayerPage extends StatefulWidget { this.handoff, this.revealAfterFirstFrame = false, this.initialFrameBytes, + this.initialFrameProvider, }); final Drama drama; @@ -122,6 +123,7 @@ class PlayerPage extends StatefulWidget { final PlayerPageHandoff? handoff; final bool revealAfterFirstFrame; final Uint8List? initialFrameBytes; + final ImageProvider? initialFrameProvider; @override State createState() => _PlayerPageState(); @@ -147,6 +149,7 @@ class _PlayerPageState extends State late bool _firstFrameReady; bool _leavingHandoff = false; bool _preparingFullPlayer = false; + bool _loggedInitialFrameBuild = false; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -157,14 +160,6 @@ class _PlayerPageState extends State void initState() { super.initState(); _firstFrameReady = !widget.revealAfterFirstFrame; - if (kDebugMode && - widget.revealAfterFirstFrame && - widget.initialFrameBytes != null) { - debugPrint( - '[player-entry] phase=initial-frame-shown ' - 'bytes=${widget.initialFrameBytes!.length}', - ); - } final handoff = widget.handoff; logic = PlayerBinding().create( history: context.read(), @@ -690,6 +685,18 @@ class _PlayerPageState extends State logic.ticketError != null || logic.blockedEpisodeId != null || logic.session.error != null; + final hasInitialFrame = + widget.initialFrameBytes != null || widget.initialFrameProvider != null; + if (kDebugMode && + widget.revealAfterFirstFrame && + hasInitialFrame && + !_loggedInitialFrameBuild) { + _loggedInitialFrameBuild = true; + debugPrint( + '[player-entry] phase=first-build-with-initial-frame ' + 'kind=${widget.initialFrameBytes != null ? 'capture' : 'poster'}', + ); + } final presentedPlayer = widget.revealAfterFirstFrame ? Stack( fit: StackFit.expand, @@ -698,7 +705,15 @@ class _PlayerPageState extends State child: revealContent ? const SizedBox.expand() : widget.initialFrameBytes == null - ? _buildInitialFrameFallback() + ? widget.initialFrameProvider == null + ? _buildInitialFrameFallback() + : Image( + image: widget.initialFrameProvider!, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => + _buildInitialFrameFallback(), + ) : Image.memory( widget.initialFrameBytes!, key: const Key('player-entry-frame'), @@ -726,7 +741,7 @@ class _PlayerPageState extends State setState(() => _firstFrameReady = true); if (kDebugMode) { debugPrint( - '[player-entry] phase=ready ' + '[player-entry] phase=video-first-frame ' 'session=${identityHashCode(logic.session)}', ); } @@ -1245,10 +1260,15 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } - final initialFrameBytes = await PlayerFrameCapture.capture( + final fallbackPosterUrl = + episodeIndex >= 0 && episodeIndex < seriesEpisodes.length + ? seriesEpisodes[episodeIndex].posterUrl + : sourceEpisode.posterUrl; + final preparedFrame = await PlayerFrameCapture.captureAndPrecache( context: context, boundaryKey: logic.renderBoundaryKey, source: 'full-player', + fallbackPosterUrl: fallbackPosterUrl, ); if (!mounted || !context.mounted) return; final sourceUserWantsPlay = logic.session.userWantsPlay; @@ -1265,21 +1285,24 @@ class _PlayerPageState extends State PageRouteBuilder( opaque: true, barrierDismissible: false, - transitionDuration: const Duration(milliseconds: 180), - reverseTransitionDuration: const Duration(milliseconds: 180), - transitionsBuilder: (_, animation, _, child) => - FadeTransition(opacity: animation, child: child), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, pageBuilder: (_, _, _) => PlayerPage( drama: seriesDrama, initialIndex: episodeIndex, handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, - revealAfterFirstFrame: initialFrameBytes != null, - initialFrameBytes: initialFrameBytes, + revealAfterFirstFrame: preparedFrame.isReady, + initialFrameBytes: preparedFrame.bytes, + initialFrameProvider: preparedFrame.provider, ), ), ); + if (kDebugMode) { + debugPrint('[player-entry] phase=route-pushed source=full-player ms=0'); + } + unawaited(logic.saveCurrentProgress()); await route; if (!mounted) return; logic.attach(widget, isMounted: () => mounted); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 3db6c3c..dff4092 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -136,7 +136,11 @@ void main() { final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); final fullPlayerWidget = tester.widget(fullPlayer); - expect(fullPlayerWidget.initialFrameBytes, isNotNull); + expect( + fullPlayerWidget.initialFrameBytes != null || + fullPlayerWidget.initialFrameProvider != null, + isTrue, + ); expect(fullPlayerWidget.revealAfterFirstFrame, isTrue); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller))); @@ -265,7 +269,11 @@ void main() { ); expect(player, findsOneWidget); final playerWidget = tester.widget(player); - expect(playerWidget.initialFrameBytes, isNotNull); + expect( + playerWidget.initialFrameBytes != null || + playerWidget.initialFrameProvider != null, + isTrue, + ); expect(playerWidget.revealAfterFirstFrame, isTrue); expect(playerWidget.initialIndex, 1); diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 3f7e221..dde01b8 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -558,7 +558,14 @@ Future _waitForFullPlayer(WidgetTester tester) async { () => Future.delayed(const Duration(milliseconds: 10)), ); await tester.pump(const Duration(milliseconds: 16)); - if (find.byType(PlayerPage).evaluate().length > 1) return; + if (find + .byWidgetPredicate( + (widget) => widget is PlayerPage && !widget.homeMode, + ) + .evaluate() + .isNotEmpty) { + return; + } } fail('full player route was not pushed after frame capture'); }