fix(player): 观看全集复用抓帧首屏
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> Function() _waitForFrame;
|
||||
|
||||
late PageController pages;
|
||||
final GlobalKey renderBoundaryKey = GlobalKey(
|
||||
debugLabel: 'player-render-boundary',
|
||||
);
|
||||
late PlaybackQueue queue;
|
||||
late List<int> 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<PlayerPage>
|
||||
bool _swipePrewarmCaptured = false;
|
||||
bool _presentationActive = false;
|
||||
late bool _firstFrameReady;
|
||||
final GlobalKey _renderBoundaryKey = GlobalKey();
|
||||
bool _leavingHandoff = false;
|
||||
bool _preparingFullPlayer = false;
|
||||
final Set<String> _precachedPosterUrls = {};
|
||||
@@ -159,6 +157,14 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
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<WatchHistoryRepository>(),
|
||||
@@ -605,7 +611,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
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<PlayerPage>
|
||||
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<PlayerPage>
|
||||
_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 {
|
||||
if (_leavingHandoff) return;
|
||||
setState(() => _leavingHandoff = true);
|
||||
|
||||
@@ -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<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 {
|
||||
final factory = _RouteVideoFactory();
|
||||
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');
|
||||
}
|
||||
|
||||
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 {
|
||||
final List<_RouteVideoPlayer> created = [];
|
||||
final List<Episode> episodes = [];
|
||||
|
||||
Reference in new issue
Block a user