diff --git a/lib/pages/player/player_frame_capture.dart b/lib/pages/player/player_frame_capture.dart new file mode 100644 index 0000000..bd83025 --- /dev/null +++ b/lib/pages/player/player_frame_capture.dart @@ -0,0 +1,63 @@ +// Author: fruit.com.cn + +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class PlayerFrameCapture { + const PlayerFrameCapture._(); + + static const int budgetMilliseconds = 32; + + static Future capture({ + required BuildContext context, + required GlobalKey boundaryKey, + required String source, + }) async { + final boundary = boundaryKey.currentContext?.findRenderObject(); + if (boundary is! RenderRepaintBoundary) { + _log('[player-entry] phase=frame-capture-unavailable source=$source'); + return null; + } + final stopwatch = Stopwatch()..start(); + try { + final pixelRatio = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 2.0).toDouble(); + final image = await boundary.toImage(pixelRatio: pixelRatio); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (stopwatch.elapsedMilliseconds > budgetMilliseconds) { + _log( + '[player-entry] phase=frame-capture-over-budget source=$source ' + 'ms=${stopwatch.elapsedMilliseconds} budget_ms=$budgetMilliseconds', + ); + } + if (data == null) { + _log('[player-entry] phase=frame-capture-empty source=$source'); + return null; + } + final bytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + _log( + '[player-entry] phase=frame-captured source=$source ' + 'bytes=${bytes.length} ms=${stopwatch.elapsedMilliseconds}', + ); + return bytes; + } catch (error) { + _log( + '[player-entry] phase=frame-capture-failed source=$source ' + 'error=$error', + ); + return null; + } + } + + 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 69dfcb0..fea8fb1 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -32,6 +32,7 @@ import '../discover/search_page.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_types.dart'; import '../mine/mine_view.dart'; +import 'player_frame_capture.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; import 'widgets/episode_picker_sheet.dart'; @@ -64,6 +65,9 @@ class PlayerLogic extends ChangeNotifier { final Future Function() _waitForFrame; late PageController pages; + final GlobalKey renderBoundaryKey = GlobalKey( + debugLabel: 'player-render-boundary', + ); late PlaybackQueue queue; late List visibleIndices; late PlaybackSession session; @@ -1226,6 +1230,12 @@ class PlayerLogic extends ChangeNotifier { Drama drama, int index, ) async { + final initialFrameBytes = await PlayerFrameCapture.capture( + context: context, + boundaryKey: renderBoundaryKey, + source: 'watch-all', + ); + if (!context.mounted) return; await pauseForFullPlayer(); if (!context.mounted) return; await Navigator.of(context).push( @@ -1235,6 +1245,8 @@ class PlayerLogic extends ChangeNotifier { initialIndex: index, wallet: wallet, downloads: downloads, + revealAfterFirstFrame: initialFrameBytes != null, + initialFrameBytes: initialFrameBytes, ), ), ); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 451c720..e59148f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1,11 +1,9 @@ // Author: fruit.com.cn import 'dart:async'; -import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -34,6 +32,7 @@ import '../../l10n/app_localizations.dart'; import '../home/home_feed_channel.dart'; import 'comments_auth.dart'; import 'player_binding.dart'; +import 'player_frame_capture.dart'; import 'player_logic.dart'; import 'widgets/home_feed_header.dart'; import 'widgets/home_feed_overlay.dart'; @@ -146,7 +145,6 @@ class _PlayerPageState extends State bool _swipePrewarmCaptured = false; bool _presentationActive = false; late bool _firstFrameReady; - final GlobalKey _renderBoundaryKey = GlobalKey(); bool _leavingHandoff = false; bool _preparingFullPlayer = false; final Set _precachedPosterUrls = {}; @@ -159,6 +157,14 @@ 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(), @@ -605,7 +611,7 @@ class _PlayerPageState extends State fit: StackFit.expand, children: [ RepaintBoundary( - key: _renderBoundaryKey, + key: logic.renderBoundaryKey, child: PlayerRenderStack( pageController: _pageController, slots: _renderSlots(indices), @@ -1239,7 +1245,11 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } - final initialFrameBytes = await _captureCurrentFrame(); + final initialFrameBytes = await PlayerFrameCapture.capture( + context: context, + boundaryKey: logic.renderBoundaryKey, + source: 'full-player', + ); if (!mounted || !context.mounted) return; final sourceUserWantsPlay = logic.session.userWantsPlay; try { @@ -1279,37 +1289,6 @@ class _PlayerPageState extends State _logFullPlayerRoute('restored'); } - Future _captureCurrentFrame() async { - final boundary = _renderBoundaryKey.currentContext?.findRenderObject(); - if (boundary is! RenderRepaintBoundary) return null; - final startedAt = DateTime.now(); - try { - final pixelRatio = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 2.0).toDouble(); - final image = await boundary.toImage(pixelRatio: pixelRatio); - final data = await image.toByteData(format: ui.ImageByteFormat.png); - image.dispose(); - Uint8List? bytes; - if (data != null) { - bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); - } - if (kDebugMode) { - debugPrint( - '[player-entry] phase=frame-captured ' - 'bytes=${bytes?.length ?? 0} ' - 'ms=${DateTime.now().difference(startedAt).inMilliseconds}', - ); - } - return bytes; - } catch (error) { - if (kDebugMode) { - debugPrint('[player-entry] phase=frame-capture-failed error=$error'); - } - return null; - } - } - Future _leaveHandoff(BuildContext context) async { if (_leavingHandoff) return; setState(() => _leavingHandoff = true); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 62d2105..3db6c3c 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -246,6 +246,34 @@ void main() { expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); + testWidgets('should_capture_feed_frame_when_opening_series_from_picker', ( + tester, + ) async { + final factory = _RouteVideoFactory(); + await _pumpApp(tester, playerFactory: factory.create); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('rail-episodes'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-episode-2'))); + await _waitForRevealedPlayerEntry(tester); + await tester.pumpAndSettle(); + + final player = find.byWidgetPredicate( + (widget) => widget is PlayerPage && widget.revealAfterFirstFrame, + ); + expect(player, findsOneWidget); + final playerWidget = tester.widget(player); + expect(playerWidget.initialFrameBytes, isNotNull); + expect(playerWidget.revealAfterFirstFrame, isTrue); + expect(playerWidget.initialIndex, 1); + + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + }); + testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async { final factory = _RouteVideoFactory(); await _pumpApp(tester, playerFactory: factory.create); @@ -1675,6 +1703,24 @@ Future _waitForFullPlayerEntry(WidgetTester tester) async { fail('full player route was not pushed after frame capture'); } +Future _waitForRevealedPlayerEntry(WidgetTester tester) async { + for (var attempt = 0; attempt < 120; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 16)); + if (find + .byWidgetPredicate( + (widget) => widget is PlayerPage && widget.revealAfterFirstFrame, + ) + .evaluate() + .isNotEmpty) { + return; + } + } + fail('revealed player route was not pushed after frame capture'); +} + class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; final List episodes = [];