fix(player): 预解码观看全集首帧
This commit is contained in:
1 parent
1fc8c4924e
commit
c8ec481fc9
5 files changed
+145
-26
No files matched your search
@@ -6,11 +6,56 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.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<Object>? provider;
|
||||||
|
|
||||||
|
bool get isReady => bytes != null || provider != null;
|
||||||
|
}
|
||||||
|
|
||||||
class PlayerFrameCapture {
|
class PlayerFrameCapture {
|
||||||
const PlayerFrameCapture._();
|
const PlayerFrameCapture._();
|
||||||
|
|
||||||
static const int budgetMilliseconds = 32;
|
static const int budgetMilliseconds = 32;
|
||||||
|
|
||||||
|
static Future<PreparedPlayerFrame> 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<Uint8List?> capture({
|
static Future<Uint8List?> capture({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required GlobalKey boundaryKey,
|
required GlobalKey boundaryKey,
|
||||||
@@ -57,6 +102,32 @@ class PlayerFrameCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<bool> _precache(
|
||||||
|
BuildContext context,
|
||||||
|
ImageProvider<Object> 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) {
|
static void _log(String message) {
|
||||||
if (kDebugMode) debugPrint(message);
|
if (kDebugMode) debugPrint(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
cancelScrub();
|
cancelScrub();
|
||||||
await session.pauseForRouteHandoff();
|
await session.pauseForRouteHandoff();
|
||||||
_logFeedHandoff('paused');
|
_logFeedHandoff('paused');
|
||||||
await saveCurrentProgress();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。
|
/// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。
|
||||||
@@ -1230,27 +1229,38 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
Drama drama,
|
Drama drama,
|
||||||
int index,
|
int index,
|
||||||
) async {
|
) 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,
|
context: context,
|
||||||
boundaryKey: renderBoundaryKey,
|
boundaryKey: renderBoundaryKey,
|
||||||
source: 'watch-all',
|
source: 'watch-all',
|
||||||
|
fallbackPosterUrl: fallbackPosterUrl,
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await pauseForFullPlayer();
|
await pauseForFullPlayer();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await Navigator.of(context).push(
|
final route = Navigator.of(context).push(
|
||||||
FadePageRoute<void>(
|
FadePageRoute<void>(
|
||||||
transitionDuration: const Duration(milliseconds: 180),
|
transitionDuration: Duration.zero,
|
||||||
|
reverseTransitionDuration: Duration.zero,
|
||||||
builder: (_) => PlayerPage(
|
builder: (_) => PlayerPage(
|
||||||
drama: drama,
|
drama: drama,
|
||||||
initialIndex: index,
|
initialIndex: index,
|
||||||
wallet: wallet,
|
wallet: wallet,
|
||||||
downloads: downloads,
|
downloads: downloads,
|
||||||
revealAfterFirstFrame: initialFrameBytes != null,
|
revealAfterFirstFrame: preparedFrame.isReady,
|
||||||
initialFrameBytes: initialFrameBytes,
|
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;
|
if (!isMounted()) return;
|
||||||
await resumeAfterFullPlayer();
|
await resumeAfterFullPlayer();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class PlayerPage extends StatefulWidget {
|
|||||||
this.handoff,
|
this.handoff,
|
||||||
this.revealAfterFirstFrame = false,
|
this.revealAfterFirstFrame = false,
|
||||||
this.initialFrameBytes,
|
this.initialFrameBytes,
|
||||||
|
this.initialFrameProvider,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Drama drama;
|
final Drama drama;
|
||||||
@@ -122,6 +123,7 @@ class PlayerPage extends StatefulWidget {
|
|||||||
final PlayerPageHandoff? handoff;
|
final PlayerPageHandoff? handoff;
|
||||||
final bool revealAfterFirstFrame;
|
final bool revealAfterFirstFrame;
|
||||||
final Uint8List? initialFrameBytes;
|
final Uint8List? initialFrameBytes;
|
||||||
|
final ImageProvider<Object>? initialFrameProvider;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PlayerPage> createState() => _PlayerPageState();
|
State<PlayerPage> createState() => _PlayerPageState();
|
||||||
@@ -147,6 +149,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
late bool _firstFrameReady;
|
late bool _firstFrameReady;
|
||||||
bool _leavingHandoff = false;
|
bool _leavingHandoff = false;
|
||||||
bool _preparingFullPlayer = false;
|
bool _preparingFullPlayer = false;
|
||||||
|
bool _loggedInitialFrameBuild = false;
|
||||||
final Set<String> _precachedPosterUrls = {};
|
final Set<String> _precachedPosterUrls = {};
|
||||||
|
|
||||||
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
||||||
@@ -157,14 +160,6 @@ 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>(),
|
||||||
@@ -690,6 +685,18 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
logic.ticketError != null ||
|
logic.ticketError != null ||
|
||||||
logic.blockedEpisodeId != null ||
|
logic.blockedEpisodeId != null ||
|
||||||
logic.session.error != 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
|
final presentedPlayer = widget.revealAfterFirstFrame
|
||||||
? Stack(
|
? Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
@@ -698,7 +705,15 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
child: revealContent
|
child: revealContent
|
||||||
? const SizedBox.expand()
|
? const SizedBox.expand()
|
||||||
: widget.initialFrameBytes == null
|
: widget.initialFrameBytes == null
|
||||||
? _buildInitialFrameFallback()
|
? widget.initialFrameProvider == null
|
||||||
|
? _buildInitialFrameFallback()
|
||||||
|
: Image(
|
||||||
|
image: widget.initialFrameProvider!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, _, _) =>
|
||||||
|
_buildInitialFrameFallback(),
|
||||||
|
)
|
||||||
: Image.memory(
|
: Image.memory(
|
||||||
widget.initialFrameBytes!,
|
widget.initialFrameBytes!,
|
||||||
key: const Key('player-entry-frame'),
|
key: const Key('player-entry-frame'),
|
||||||
@@ -726,7 +741,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
setState(() => _firstFrameReady = true);
|
setState(() => _firstFrameReady = true);
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[player-entry] phase=ready '
|
'[player-entry] phase=video-first-frame '
|
||||||
'session=${identityHashCode(logic.session)}',
|
'session=${identityHashCode(logic.session)}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1245,10 +1260,15 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
seriesEpisodes.insert(0, sourceEpisode);
|
seriesEpisodes.insert(0, sourceEpisode);
|
||||||
episodeIndex = 0;
|
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,
|
context: context,
|
||||||
boundaryKey: logic.renderBoundaryKey,
|
boundaryKey: logic.renderBoundaryKey,
|
||||||
source: 'full-player',
|
source: 'full-player',
|
||||||
|
fallbackPosterUrl: fallbackPosterUrl,
|
||||||
);
|
);
|
||||||
if (!mounted || !context.mounted) return;
|
if (!mounted || !context.mounted) return;
|
||||||
final sourceUserWantsPlay = logic.session.userWantsPlay;
|
final sourceUserWantsPlay = logic.session.userWantsPlay;
|
||||||
@@ -1265,21 +1285,24 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
PageRouteBuilder<void>(
|
PageRouteBuilder<void>(
|
||||||
opaque: true,
|
opaque: true,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
transitionDuration: const Duration(milliseconds: 180),
|
transitionDuration: Duration.zero,
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 180),
|
reverseTransitionDuration: Duration.zero,
|
||||||
transitionsBuilder: (_, animation, _, child) =>
|
|
||||||
FadeTransition(opacity: animation, child: child),
|
|
||||||
pageBuilder: (_, _, _) => PlayerPage(
|
pageBuilder: (_, _, _) => PlayerPage(
|
||||||
drama: seriesDrama,
|
drama: seriesDrama,
|
||||||
initialIndex: episodeIndex,
|
initialIndex: episodeIndex,
|
||||||
handoff: handoff,
|
handoff: handoff,
|
||||||
wallet: logic.wallet,
|
wallet: logic.wallet,
|
||||||
downloads: logic.downloads,
|
downloads: logic.downloads,
|
||||||
revealAfterFirstFrame: initialFrameBytes != null,
|
revealAfterFirstFrame: preparedFrame.isReady,
|
||||||
initialFrameBytes: initialFrameBytes,
|
initialFrameBytes: preparedFrame.bytes,
|
||||||
|
initialFrameProvider: preparedFrame.provider,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[player-entry] phase=route-pushed source=full-player ms=0');
|
||||||
|
}
|
||||||
|
unawaited(logic.saveCurrentProgress());
|
||||||
await route;
|
await route;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
logic.attach(widget, isMounted: () => mounted);
|
logic.attach(widget, isMounted: () => mounted);
|
||||||
|
|||||||
@@ -136,7 +136,11 @@ void main() {
|
|||||||
final fullLogic = _pageLogic(tester, fullPlayer);
|
final fullLogic = _pageLogic(tester, fullPlayer);
|
||||||
expect(fullLogic.session, isNot(same(sourceLogic.session)));
|
expect(fullLogic.session, isNot(same(sourceLogic.session)));
|
||||||
final fullPlayerWidget = tester.widget<PlayerPage>(fullPlayer);
|
final fullPlayerWidget = tester.widget<PlayerPage>(fullPlayer);
|
||||||
expect(fullPlayerWidget.initialFrameBytes, isNotNull);
|
expect(
|
||||||
|
fullPlayerWidget.initialFrameBytes != null ||
|
||||||
|
fullPlayerWidget.initialFrameProvider != null,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
expect(fullPlayerWidget.revealAfterFirstFrame, isTrue);
|
expect(fullPlayerWidget.revealAfterFirstFrame, isTrue);
|
||||||
final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort;
|
final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort;
|
||||||
expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller)));
|
expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller)));
|
||||||
@@ -265,7 +269,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(player, findsOneWidget);
|
expect(player, findsOneWidget);
|
||||||
final playerWidget = tester.widget<PlayerPage>(player);
|
final playerWidget = tester.widget<PlayerPage>(player);
|
||||||
expect(playerWidget.initialFrameBytes, isNotNull);
|
expect(
|
||||||
|
playerWidget.initialFrameBytes != null ||
|
||||||
|
playerWidget.initialFrameProvider != null,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
expect(playerWidget.revealAfterFirstFrame, isTrue);
|
expect(playerWidget.revealAfterFirstFrame, isTrue);
|
||||||
expect(playerWidget.initialIndex, 1);
|
expect(playerWidget.initialIndex, 1);
|
||||||
|
|
||||||
|
|||||||
@@ -558,7 +558,14 @@ Future<void> _waitForFullPlayer(WidgetTester tester) async {
|
|||||||
() => Future<void>.delayed(const Duration(milliseconds: 10)),
|
() => Future<void>.delayed(const Duration(milliseconds: 10)),
|
||||||
);
|
);
|
||||||
await tester.pump(const Duration(milliseconds: 16));
|
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');
|
fail('full player route was not pushed after frame capture');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user