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 '../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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
Reference in new issue
Block a user