fix(player): 播放页改为非阻塞加载

This commit is contained in:
Zhou Weidong committed 2026-10-26 17:43:57 +08:00
1 parent e8f192ccf8
commit ee4e573f4f
7 files changed
+239 -455

No files matched your search

-134
View File
@@ -1,134 +0,0 @@
// Author: fruit.com.cn
import 'dart:ui' as ui;
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,
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 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);
}
}
+1 -19
View File
@@ -32,7 +32,6 @@ 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';
@@ -65,9 +64,6 @@ 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;
@@ -1229,18 +1225,6 @@ class PlayerLogic extends ChangeNotifier {
Drama drama,
int index,
) async {
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;
final route = Navigator.of(context).push(
FadePageRoute<void>(
transitionDuration: Duration.zero,
@@ -1250,15 +1234,13 @@ class PlayerLogic extends ChangeNotifier {
initialIndex: index,
wallet: wallet,
downloads: downloads,
revealAfterFirstFrame: preparedFrame.isReady,
initialFrameBytes: preparedFrame.bytes,
initialFrameProvider: preparedFrame.provider,
),
),
);
if (kDebugMode) {
debugPrint('[player-entry] phase=route-pushed source=watch-all ms=0');
}
unawaited(pauseForFullPlayer());
unawaited(saveCurrentProgress());
await route;
if (!isMounted()) return;
+79 -223
View File
@@ -32,13 +32,13 @@ 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';
import 'widgets/exit_clear_screen_button.dart';
import 'widgets/feed_pull_refresh.dart';
import 'widgets/player_center_toggle.dart';
import 'widgets/player_loading_overlay.dart';
import 'widgets/player_more_sheet.dart';
import 'widgets/player_overlay.dart';
import 'widgets/player_overlay_models.dart';
@@ -91,9 +91,6 @@ class PlayerPage extends StatefulWidget {
this.wallet,
this.downloads,
this.handoff,
this.revealAfterFirstFrame = false,
this.initialFrameBytes,
this.initialFrameProvider,
});
final Drama drama;
@@ -121,9 +118,6 @@ class PlayerPage extends StatefulWidget {
final WalletController? wallet;
final DownloadController? downloads;
final PlayerPageHandoff? handoff;
final bool revealAfterFirstFrame;
final Uint8List? initialFrameBytes;
final ImageProvider<Object>? initialFrameProvider;
@override
State<PlayerPage> createState() => _PlayerPageState();
@@ -146,16 +140,13 @@ class _PlayerPageState extends State<PlayerPage>
DateTime? _swipeStartedAt;
bool _swipePrewarmCaptured = false;
bool _presentationActive = false;
late bool _firstFrameReady;
late bool _decorVisible;
late bool _decorRemoved;
bool _decorFadeScheduled = false;
bool _leavingHandoff = false;
bool _preparingFullPlayer = false;
bool _loggedInitialFrameBuild = false;
bool _loggedDecorCovered = false;
DateTime? _entryDiagnosticsStartedAt;
final Map<String, bool> _entryElementStates = {};
int? _loadingIndex;
int? _videoReadyIndex;
bool _loadingVisible = false;
bool _loadingRemoved = true;
bool _loadingFadeScheduled = false;
final Set<String> _precachedPosterUrls = {};
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
@@ -165,14 +156,6 @@ class _PlayerPageState extends State<PlayerPage>
@override
void initState() {
super.initState();
_firstFrameReady = !widget.revealAfterFirstFrame;
final hasInitialFrame =
widget.initialFrameBytes != null || widget.initialFrameProvider != null;
_decorVisible = widget.revealAfterFirstFrame && hasInitialFrame;
_decorRemoved = !_decorVisible;
if (widget.revealAfterFirstFrame) {
_entryDiagnosticsStartedAt = DateTime.now();
}
final handoff = widget.handoff;
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
@@ -196,11 +179,10 @@ class _PlayerPageState extends State<PlayerPage>
initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true,
);
_pageController = logic.pages;
if (kDebugMode && widget.revealAfterFirstFrame) {
debugPrint(
'[player-entry] phase=waiting-first-frame '
'session=${identityHashCode(logic.session)}',
);
if (!widget.embedded) {
_loadingIndex = logic.session.currentIndex;
_loadingVisible = true;
_loadingRemoved = false;
}
}
@@ -562,6 +544,33 @@ class _PlayerPageState extends State<PlayerPage>
final interactions = context.watch<InteractionController>();
final l10n = AppLocalizations.of(context)!;
final indices = logic.visibleIndices;
final currentIndex = logic.session.currentIndex;
if (!widget.embedded && _loadingIndex != currentIndex) {
_loadingIndex = currentIndex;
_videoReadyIndex = null;
_loadingVisible = true;
_loadingRemoved = false;
_loadingFadeScheduled = false;
}
final entryUnavailable =
logic.ticketError != null ||
logic.blockedEpisodeId != null ||
logic.session.error != null ||
logic.isEpisodeLocked(logic.session.currentEpisode);
if (!widget.embedded &&
!_loadingRemoved &&
!_loadingFadeScheduled &&
entryUnavailable) {
_scheduleLoadingFade('entry-unavailable');
}
final activePort = logic.session.active;
if (!widget.embedded &&
!_loadingRemoved &&
!_loadingFadeScheduled &&
activePort != null &&
activePort is! VideoPlayerPort) {
_scheduleLoadingFade('non-video-player-ready');
}
final content = AnimatedBuilder(
animation: logic.session,
builder: (context, _) {
@@ -618,14 +627,11 @@ class _PlayerPageState extends State<PlayerPage>
return Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
key: logic.renderBoundaryKey,
child: PlayerRenderStack(
pageController: _pageController,
slots: _renderSlots(indices),
onCurrentVideoVisible: _handleCurrentVideoVisible,
showNeighbors: !widget.revealAfterFirstFrame || _decorRemoved,
),
PlayerRenderStack(
pageController: _pageController,
slots: _renderSlots(indices),
onCurrentVideoVisible: _handleCurrentVideoVisible,
showNeighbors: widget.embedded || _loadingRemoved,
),
pageLayer,
],
@@ -669,6 +675,14 @@ class _PlayerPageState extends State<PlayerPage>
),
),
),
if (!widget.embedded && !_loadingRemoved)
Positioned.fill(
child: PlayerLoadingOverlay(
key: ValueKey('player-loading-${_loadingIndex ?? currentIndex}'),
opacity: _loadingVisible ? 1 : 0,
onFadeEnd: _handleLoadingFadeEnd,
),
),
],
);
final player = AnnotatedRegion<SystemUiOverlayStyle>(
@@ -692,78 +706,25 @@ class _PlayerPageState extends State<PlayerPage>
),
),
);
_logEntryElement('player-ui', true);
_logEntryElement(
'home-feed-header',
widget.homeMode &&
widget.showHomeFeedHeader &&
!_scrubbing &&
!_boosting &&
!logic.pip &&
!logic.pipPreparing,
);
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 usesInitialFrame = widget.revealAfterFirstFrame && hasInitialFrame;
if (usesInitialFrame &&
_decorVisible &&
!_loggedDecorCovered &&
kDebugMode) {
_loggedDecorCovered = true;
debugPrint('[player-entry] phase=decor-covers-video');
}
final entryUnavailableReason = logic.ticketError != null
? 'ticket-error'
: logic.blockedEpisodeId != null
? 'blocked-episode'
: null;
if (usesInitialFrame && _decorVisible && entryUnavailableReason != null) {
_scheduleDecorFade(entryUnavailableReason);
}
final presentedPlayer = usesInitialFrame
? Stack(
fit: StackFit.expand,
children: [
player,
if (!_decorRemoved)
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
key: const Key('player-entry-decor'),
opacity: _decorVisible ? 1 : 0,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
onEnd: _handleDecorFadeEnd,
child: _buildInitialFrameImage(),
),
),
),
],
)
: player;
if (widget.handoff == null) return presentedPlayer;
if (widget.handoff == null) return player;
return PopScope<void>(
canPop: _leavingHandoff,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_leaveHandoff(context));
},
child: presentedPlayer,
child: player,
);
}
void _handleCurrentVideoVisible() {
if (!mounted || _firstFrameReady) return;
_firstFrameReady = true;
void _handleCurrentVideoVisible(int? index) {
if (!mounted) return;
final currentIndex = logic.session.currentIndex;
if (index == null ||
index != currentIndex ||
_videoReadyIndex == currentIndex) {
return;
}
_videoReadyIndex = currentIndex;
if (kDebugMode) {
debugPrint(
'[player-entry] phase=video-first-frame '
@@ -772,95 +733,41 @@ class _PlayerPageState extends State<PlayerPage>
}
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_startDecorFade('video-first-frame');
_startLoadingFade(currentIndex);
});
});
}
void _scheduleDecorFade(String reason) {
void _scheduleLoadingFade(String reason) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_startDecorFade(reason);
_startLoadingFade(logic.session.currentIndex, reason: reason);
});
}
void _startDecorFade(String reason) {
if (!mounted || !_decorVisible || _decorRemoved || _decorFadeScheduled) {
void _startLoadingFade(int index, {String reason = 'video-first-frame'}) {
if (!mounted ||
widget.embedded ||
index != logic.session.currentIndex ||
!_loadingVisible ||
_loadingRemoved ||
_loadingFadeScheduled) {
return;
}
_decorFadeScheduled = true;
_loadingFadeScheduled = true;
if (kDebugMode) {
debugPrint('[player-entry] phase=decor-fade-start reason=$reason');
debugPrint('[player-entry] phase=loading-fade-start reason=$reason');
}
setState(() => _decorVisible = false);
setState(() => _loadingVisible = false);
}
void _handleDecorFadeEnd() {
if (!mounted || _decorVisible || _decorRemoved) return;
setState(() => _decorRemoved = true);
void _handleLoadingFadeEnd() {
if (!mounted || _loadingVisible || _loadingRemoved) return;
setState(() => _loadingRemoved = true);
if (kDebugMode) {
debugPrint('[player-entry] phase=decor-removed');
debugPrint('[player-entry] phase=loading-removed');
}
}
Widget _buildInitialFrameImage() {
if (widget.initialFrameBytes != null) {
return Image.memory(
widget.initialFrameBytes!,
key: const Key('player-entry-frame'),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _buildInitialFrameFallback(),
);
}
final provider = widget.initialFrameProvider;
if (provider != null) {
return Image(
image: provider,
key: const Key('player-entry-frame'),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _buildInitialFrameFallback(),
);
}
return _buildInitialFrameFallback();
}
void _logEntryElement(String element, bool visible) {
final startedAt = _entryDiagnosticsStartedAt;
if (startedAt == null ||
DateTime.now().difference(startedAt) >
const Duration(milliseconds: 800)) {
return;
}
if (_entryElementStates[element] == visible) return;
_entryElementStates[element] = visible;
if (kDebugMode) {
debugPrint(
'[player-entry] phase=element-state element=$element '
'visible=${visible ? 1 : 0} '
'ms=${DateTime.now().difference(startedAt).inMilliseconds}',
);
}
}
Widget _buildInitialFrameFallback() {
final index = widget.initialIndex;
if (index < 0 || index >= widget.drama.episodes.length) {
return const ColoredBox(
key: Key('player-entry-black'),
color: AppColors.mediaBlack,
);
}
final episode = widget.drama.episodes[index];
return playerOpeningFrame(
episode.thumbnailsUrl,
posterUrl: episode.posterUrl,
spriteSource: _spriteAxisService,
fit: BoxFit.cover,
) ??
const ColoredBox(color: AppColors.mediaBlack);
}
void _openCurrentMore() {
final entry = logic.queue.entryAt(logic.session.currentIndex);
_openMore(context, entry.episode);
@@ -897,11 +804,6 @@ class _PlayerPageState extends State<PlayerPage>
logic.session.previousPreloadedIndex;
final next =
logic.nextVisibleIndex(current) ?? logic.session.preloadedIndex;
_logEntryElement(
'adjacent-slots',
(!widget.revealAfterFirstFrame || _decorRemoved) &&
(previous != null || next != null),
);
return {
PlayerRenderSlotRole.previous: _renderSlot(
PlayerRenderSlotRole.previous,
@@ -948,33 +850,6 @@ class _PlayerPageState extends State<PlayerPage>
final handoffPort = handoff is VideoPlayerPort ? handoff : null;
final videoReady = videoPort?.controller.value.isInitialized ?? false;
final theater = logic.pip || logic.pipPreparing;
if (selected) {
final videoLoading =
!locked &&
!theater &&
!videoReady &&
port == null &&
!logic.ticketLoading &&
logic.ticketError == null &&
logic.session.error == null &&
logic.blockedEpisodeId != episode.id &&
!logic.session.isLocked;
_logEntryElement('loading-spinner', videoLoading);
_logEntryElement(
'opening-frame',
!locked && !videoReady && episode.posterUrl?.isNotEmpty == true ||
!locked && !videoReady && episode.thumbnailsUrl?.isNotEmpty == true,
);
_logEntryElement('pause-button', !locked && !logic.session.userWantsPlay);
_logEntryElement(
'player-controls',
!widget.homeMode && !theater && !locked,
);
_logEntryElement(
'buffering',
videoPort?.controller.value.isBuffering ?? false,
);
}
final videoFit = theater ? BoxFit.contain : BoxFit.cover;
final preparedFrameAvailable =
!locked &&
@@ -1388,24 +1263,7 @@ class _PlayerPageState extends State<PlayerPage>
seriesEpisodes.insert(0, sourceEpisode);
episodeIndex = 0;
}
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;
try {
await logic.pauseForFullPlayer();
} finally {
_preparingFullPlayer = false;
}
if (!mounted || !context.mounted) return;
final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay);
_logFullPlayerRoute('present');
setState(() => _presentationActive = true);
@@ -1421,15 +1279,13 @@ class _PlayerPageState extends State<PlayerPage>
handoff: handoff,
wallet: logic.wallet,
downloads: logic.downloads,
revealAfterFirstFrame: preparedFrame.isReady,
initialFrameBytes: preparedFrame.bytes,
initialFrameProvider: preparedFrame.provider,
),
),
);
if (kDebugMode) {
debugPrint('[player-entry] phase=route-pushed source=full-player ms=0');
}
unawaited(logic.pauseForFullPlayer());
unawaited(logic.saveCurrentProgress());
await route;
if (!mounted) return;
@@ -0,0 +1,55 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
class PlayerLoadingOverlay extends StatelessWidget {
const PlayerLoadingOverlay({
super.key,
required this.opacity,
required this.onFadeEnd,
});
final double opacity;
final VoidCallback onFadeEnd;
@override
Widget build(BuildContext context) {
return IgnorePointer(
key: const Key('player-entry-loading-overlay'),
child: AnimatedOpacity(
opacity: opacity,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
onEnd: onFadeEnd,
child: ColoredBox(
color: AppColors.mediaBlack,
child: Center(
child: Container(
key: const Key('player-loading-surface'),
width: AppDimens.refreshIndicatorSize,
height: AppDimens.refreshIndicatorSize,
decoration: BoxDecoration(
color: AppColors.refreshIndicatorSurface,
borderRadius: BorderRadius.circular(
AppDimens.refreshIndicatorRadius,
),
),
alignment: Alignment.center,
child: const SizedBox.square(
dimension: AppDimens.refreshSpinnerSize,
child: CircularProgressIndicator(
key: Key('player-entry-loading'),
color: Colors.white,
strokeWidth: AppDimens.refreshSpinnerStroke,
),
),
),
),
),
),
);
}
}
@@ -58,7 +58,7 @@ class PlayerRenderStack extends StatefulWidget {
final PageController pageController;
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
final VoidCallback? onCurrentVideoVisible;
final ValueChanged<int?>? onCurrentVideoVisible;
final bool showNeighbors;
@override
@@ -202,7 +202,7 @@ class _PlayerRenderStackState extends State<PlayerRenderStack> {
warmSlotReused: slot.warmSlotReused,
waitForPlaybackFrame: slot.waitForPlaybackFrame,
onVideoVisible: slot.role == PlayerRenderSlotRole.current
? widget.onCurrentVideoVisible
? () => widget.onCurrentVideoVisible?.call(slot.index)
: null,
),
),