fix(player): 观看全集复用抓帧首屏

This commit is contained in:
Zhou Weidong committed 2026-10-26 17:20:24 +08:00
1 parent 4d3d067f77
commit 55fc2796a9
4 files changed
+136 -36

No files matched your search

@@ -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<Uint8List?> 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);
}
}
+12
View File
@@ -32,6 +32,7 @@ import '../discover/search_page.dart';
import '../drama_detail/drama_detail_view.dart'; import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_types.dart'; import '../mine/mine_types.dart';
import '../mine/mine_view.dart'; import '../mine/mine_view.dart';
import 'player_frame_capture.dart';
import 'player_view.dart'; import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart'; import 'widgets/drama_comments_sheet.dart';
import 'widgets/episode_picker_sheet.dart'; import 'widgets/episode_picker_sheet.dart';
@@ -64,6 +65,9 @@ class PlayerLogic extends ChangeNotifier {
final Future<void> Function() _waitForFrame; final Future<void> Function() _waitForFrame;
late PageController pages; late PageController pages;
final GlobalKey renderBoundaryKey = GlobalKey(
debugLabel: 'player-render-boundary',
);
late PlaybackQueue queue; late PlaybackQueue queue;
late List<int> visibleIndices; late List<int> visibleIndices;
late PlaybackSession session; late PlaybackSession session;
@@ -1226,6 +1230,12 @@ class PlayerLogic extends ChangeNotifier {
Drama drama, Drama drama,
int index, int index,
) async { ) async {
final initialFrameBytes = await PlayerFrameCapture.capture(
context: context,
boundaryKey: renderBoundaryKey,
source: 'watch-all',
);
if (!context.mounted) return;
await pauseForFullPlayer(); await pauseForFullPlayer();
if (!context.mounted) return; if (!context.mounted) return;
await Navigator.of(context).push( await Navigator.of(context).push(
@@ -1235,6 +1245,8 @@ class PlayerLogic extends ChangeNotifier {
initialIndex: index, initialIndex: index,
wallet: wallet, wallet: wallet,
downloads: downloads, downloads: downloads,
revealAfterFirstFrame: initialFrameBytes != null,
initialFrameBytes: initialFrameBytes,
), ),
), ),
); );
+15 -36
View File
@@ -1,11 +1,9 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -34,6 +32,7 @@ import '../../l10n/app_localizations.dart';
import '../home/home_feed_channel.dart'; import '../home/home_feed_channel.dart';
import 'comments_auth.dart'; import 'comments_auth.dart';
import 'player_binding.dart'; import 'player_binding.dart';
import 'player_frame_capture.dart';
import 'player_logic.dart'; import 'player_logic.dart';
import 'widgets/home_feed_header.dart'; import 'widgets/home_feed_header.dart';
import 'widgets/home_feed_overlay.dart'; import 'widgets/home_feed_overlay.dart';
@@ -146,7 +145,6 @@ class _PlayerPageState extends State<PlayerPage>
bool _swipePrewarmCaptured = false; bool _swipePrewarmCaptured = false;
bool _presentationActive = false; bool _presentationActive = false;
late bool _firstFrameReady; late bool _firstFrameReady;
final GlobalKey _renderBoundaryKey = GlobalKey();
bool _leavingHandoff = false; bool _leavingHandoff = false;
bool _preparingFullPlayer = false; bool _preparingFullPlayer = false;
final Set<String> _precachedPosterUrls = {}; final Set<String> _precachedPosterUrls = {};
@@ -159,6 +157,14 @@ class _PlayerPageState extends State<PlayerPage>
void initState() { void initState() {
super.initState(); super.initState();
_firstFrameReady = !widget.revealAfterFirstFrame; _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; final handoff = widget.handoff;
logic = PlayerBinding().create( logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(), history: context.read<WatchHistoryRepository>(),
@@ -605,7 +611,7 @@ class _PlayerPageState extends State<PlayerPage>
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
RepaintBoundary( RepaintBoundary(
key: _renderBoundaryKey, key: logic.renderBoundaryKey,
child: PlayerRenderStack( child: PlayerRenderStack(
pageController: _pageController, pageController: _pageController,
slots: _renderSlots(indices), slots: _renderSlots(indices),
@@ -1239,7 +1245,11 @@ class _PlayerPageState extends State<PlayerPage>
seriesEpisodes.insert(0, sourceEpisode); seriesEpisodes.insert(0, sourceEpisode);
episodeIndex = 0; episodeIndex = 0;
} }
final initialFrameBytes = await _captureCurrentFrame(); final initialFrameBytes = await PlayerFrameCapture.capture(
context: context,
boundaryKey: logic.renderBoundaryKey,
source: 'full-player',
);
if (!mounted || !context.mounted) return; if (!mounted || !context.mounted) return;
final sourceUserWantsPlay = logic.session.userWantsPlay; final sourceUserWantsPlay = logic.session.userWantsPlay;
try { try {
@@ -1279,37 +1289,6 @@ class _PlayerPageState extends State<PlayerPage>
_logFullPlayerRoute('restored'); _logFullPlayerRoute('restored');
} }
Future<Uint8List?> _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<void> _leaveHandoff(BuildContext context) async { Future<void> _leaveHandoff(BuildContext context) async {
if (_leavingHandoff) return; if (_leavingHandoff) return;
setState(() => _leavingHandoff = true); setState(() => _leavingHandoff = true);
+46
View File
@@ -246,6 +246,34 @@ void main() {
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); 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<PlayerPage>(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 { testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async {
final factory = _RouteVideoFactory(); final factory = _RouteVideoFactory();
await _pumpApp(tester, playerFactory: factory.create); await _pumpApp(tester, playerFactory: factory.create);
@@ -1675,6 +1703,24 @@ Future<void> _waitForFullPlayerEntry(WidgetTester tester) async {
fail('full player route was not pushed after frame capture'); fail('full player route was not pushed after frame capture');
} }
Future<void> _waitForRevealedPlayerEntry(WidgetTester tester) async {
for (var attempt = 0; attempt < 120; attempt++) {
await tester.runAsync(
() => Future<void>.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 { class _RouteVideoFactory {
final List<_RouteVideoPlayer> created = []; final List<_RouteVideoPlayer> created = [];
final List<Episode> episodes = []; final List<Episode> episodes = [];