fix(player): 预解码观看全集首帧

This commit is contained in:
Zhou Weidong committed 2026-10-26 17:27:43 +08:00
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/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 {
const PlayerFrameCapture._();
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({
required BuildContext context,
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) {
if (kDebugMode) debugPrint(message);
}
+16 -6
View File
@@ -230,7 +230,6 @@ class PlayerLogic extends ChangeNotifier {
cancelScrub();
await session.pauseForRouteHandoff();
_logFeedHandoff('paused');
await saveCurrentProgress();
}
/// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。
@@ -1230,27 +1229,38 @@ class PlayerLogic extends ChangeNotifier {
Drama drama,
int index,
) 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,
boundaryKey: renderBoundaryKey,
source: 'watch-all',
fallbackPosterUrl: fallbackPosterUrl,
);
if (!context.mounted) return;
await pauseForFullPlayer();
if (!context.mounted) return;
await Navigator.of(context).push(
final route = Navigator.of(context).push(
FadePageRoute<void>(
transitionDuration: const Duration(milliseconds: 180),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
builder: (_) => PlayerPage(
drama: drama,
initialIndex: index,
wallet: wallet,
downloads: downloads,
revealAfterFirstFrame: initialFrameBytes != null,
initialFrameBytes: initialFrameBytes,
revealAfterFirstFrame: preparedFrame.isReady,
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;
await resumeAfterFullPlayer();
}
+40 -17
View File
@@ -93,6 +93,7 @@ class PlayerPage extends StatefulWidget {
this.handoff,
this.revealAfterFirstFrame = false,
this.initialFrameBytes,
this.initialFrameProvider,
});
final Drama drama;
@@ -122,6 +123,7 @@ class PlayerPage extends StatefulWidget {
final PlayerPageHandoff? handoff;
final bool revealAfterFirstFrame;
final Uint8List? initialFrameBytes;
final ImageProvider<Object>? initialFrameProvider;
@override
State<PlayerPage> createState() => _PlayerPageState();
@@ -147,6 +149,7 @@ class _PlayerPageState extends State<PlayerPage>
late bool _firstFrameReady;
bool _leavingHandoff = false;
bool _preparingFullPlayer = false;
bool _loggedInitialFrameBuild = false;
final Set<String> _precachedPosterUrls = {};
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
@@ -157,14 +160,6 @@ 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>(),
@@ -690,6 +685,18 @@ class _PlayerPageState extends State<PlayerPage>
logic.ticketError != null ||
logic.blockedEpisodeId != 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
? Stack(
fit: StackFit.expand,
@@ -698,7 +705,15 @@ class _PlayerPageState extends State<PlayerPage>
child: revealContent
? const SizedBox.expand()
: widget.initialFrameBytes == null
? _buildInitialFrameFallback()
? widget.initialFrameProvider == null
? _buildInitialFrameFallback()
: Image(
image: widget.initialFrameProvider!,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) =>
_buildInitialFrameFallback(),
)
: Image.memory(
widget.initialFrameBytes!,
key: const Key('player-entry-frame'),
@@ -726,7 +741,7 @@ class _PlayerPageState extends State<PlayerPage>
setState(() => _firstFrameReady = true);
if (kDebugMode) {
debugPrint(
'[player-entry] phase=ready '
'[player-entry] phase=video-first-frame '
'session=${identityHashCode(logic.session)}',
);
}
@@ -1245,10 +1260,15 @@ class _PlayerPageState extends State<PlayerPage>
seriesEpisodes.insert(0, sourceEpisode);
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,
boundaryKey: logic.renderBoundaryKey,
source: 'full-player',
fallbackPosterUrl: fallbackPosterUrl,
);
if (!mounted || !context.mounted) return;
final sourceUserWantsPlay = logic.session.userWantsPlay;
@@ -1265,21 +1285,24 @@ class _PlayerPageState extends State<PlayerPage>
PageRouteBuilder<void>(
opaque: true,
barrierDismissible: false,
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 180),
transitionsBuilder: (_, animation, _, child) =>
FadeTransition(opacity: animation, child: child),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => PlayerPage(
drama: seriesDrama,
initialIndex: episodeIndex,
handoff: handoff,
wallet: logic.wallet,
downloads: logic.downloads,
revealAfterFirstFrame: initialFrameBytes != null,
initialFrameBytes: initialFrameBytes,
revealAfterFirstFrame: preparedFrame.isReady,
initialFrameBytes: preparedFrame.bytes,
initialFrameProvider: preparedFrame.provider,
),
),
);
if (kDebugMode) {
debugPrint('[player-entry] phase=route-pushed source=full-player ms=0');
}
unawaited(logic.saveCurrentProgress());
await route;
if (!mounted) return;
logic.attach(widget, isMounted: () => mounted);