fix(player): 规范预热回收日志并抓帧过渡

This commit is contained in:
Zhou Weidong committed 2026-10-26 17:13:56 +08:00
1 parent bb2c729a7e
commit 4d3d067f77
6 files changed
+186 -40

No files matched your search

+19 -7
View File
@@ -59,12 +59,16 @@ Future<void> _parkNeighbors(
_PlayerSlot? warmNext, _PlayerSlot? warmNext,
_PlayerSlot? warmPrevious, _PlayerSlot? warmPrevious,
) async { ) async {
await _placeNeighbor(session, old); await _placeNeighbor(session, old, roleHint: 'old-current');
await _placeNeighbor(session, warmNext); await _placeNeighbor(session, warmNext, roleHint: 'next');
await _placeNeighbor(session, warmPrevious); await _placeNeighbor(session, warmPrevious, roleHint: 'previous');
} }
Future<void> _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { Future<void> _placeNeighbor(
PlaybackSession session,
_PlayerSlot? slot, {
required String roleHint,
}) async {
if (slot == null || slot == session._active || slot.retiring) return; if (slot == null || slot == session._active || slot.retiring) return;
if (slot.owner != session.owner) { if (slot.owner != session.owner) {
session._logOwnerRejection('park', slot); session._logOwnerRejection('park', slot);
@@ -78,7 +82,7 @@ Future<void> _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async {
session._previous = slot; session._previous = slot;
return; return;
} }
await session._retire(slot); await session._retire(slot, trigger: 'park-neighbor', roleHint: roleHint);
} }
/// 当前集已经出画后,按手势方向静默准备上一集和下一集。 /// 当前集已经出画后,按手势方向静默准备上一集和下一集。
@@ -118,7 +122,11 @@ Future<void> _warmOne(
await session._enqueue(() async { await session._enqueue(() async {
if (!_stillHeld(session, candidate, next: next)) return; if (!_stillHeld(session, candidate, next: next)) return;
_clearNeighbor(session, next: next); _clearNeighbor(session, next: next);
await session._retire(candidate); await session._retire(
candidate,
trigger: 'warm-failure',
roleHint: next ? 'next' : 'previous',
);
}); });
} }
@@ -135,7 +143,11 @@ Future<_PlayerSlot?> _allocateNeighbor(
final current = next ? session._next : session._previous; final current = next ? session._next : session._previous;
if (!allowed || current?.index != index) { if (!allowed || current?.index != index) {
_clearNeighbor(session, next: next); _clearNeighbor(session, next: next);
await session._retire(current); await session._retire(
current,
trigger: 'warm-slot-invalid',
roleHint: next ? 'next' : 'previous',
);
} }
if (!_canAllocate(session, request, allowed: allowed, next: next)) { if (!_canAllocate(session, request, allowed: allowed, next: next)) {
return; return;
+48 -16
View File
@@ -138,8 +138,12 @@ class PlaybackSession extends ChangeNotifier {
_notify(); _notify();
unawaited( unawaited(
_enqueue(() async { _enqueue(() async {
await _retire(staleNext); await _retire(staleNext, trigger: 'remap-stale-next', roleHint: 'next');
await _retire(stalePrevious); await _retire(
stalePrevious,
trigger: 'remap-stale-previous',
roleHint: 'previous',
);
}), }),
); );
if (_visible && !_overlayOpen) _warmNeighbors(this); if (_visible && !_overlayOpen) _warmNeighbors(this);
@@ -190,20 +194,23 @@ class PlaybackSession extends ChangeNotifier {
return slot; return slot;
} }
Future<void> _retire(_PlayerSlot? slot) async { Future<void> _retire(
_PlayerSlot? slot, {
required String trigger,
String? roleHint,
}) async {
if (slot == null) return; if (slot == null) return;
if (slot.owner != owner) { if (slot.owner != owner) {
_logOwnerRejection('retire', slot); _logOwnerRejection('retire', slot);
return; return;
} }
if (!_live.contains(slot)) return; if (!_live.contains(slot)) return;
if (kDebugMode) { final role = _slotRole(slot, roleHint: roleHint);
debugPrint( final video = slot.player is VideoPlayerPort
'[player-owner] phase=retire owner=${slot.owner.name} ' ? slot.player as VideoPlayerPort
'drama=${slot.dramaId ?? '-'} episodeIndex=${slot.index} ' : null;
'controller=${_playerIdentity(slot.player)}', final value = video?.controller.value;
); final slotPlaying = slot.playing;
}
slot.retiring = true; slot.retiring = true;
final cleared = final cleared =
_active == slot || _active == slot ||
@@ -222,6 +229,19 @@ class PlaybackSession extends ChangeNotifier {
} finally { } finally {
await slot.player.dispose(); await slot.player.dispose();
_live.remove(slot); _live.remove(slot);
if (kDebugMode) {
debugPrint(
'[player-slot] slot_released role=$role owner=${slot.owner.name} '
'selectOwner=$owner drama=${slot.dramaId ?? '-'} '
'episodeIndex=${slot.index} '
'controller=${_playerIdentity(slot.player)} '
'slotPlaying=${slotPlaying ? 1 : 0} '
'isPlaying=${value?.isPlaying == true ? 1 : 0} '
'position=${value?.position.inMilliseconds ?? '-'} '
'currentIndex=$_currentIndex generation=$_generation '
'trigger=$trigger',
);
}
} }
} }
@@ -268,7 +288,7 @@ class PlaybackSession extends ChangeNotifier {
_PlayerSlot? target; _PlayerSlot? target;
await _enqueue(() async { await _enqueue(() async {
if (_closed || request != _generation) return; if (_closed || request != _generation) return;
await _retire(_handoff); await _retire(_handoff, trigger: 'select-handoff');
final old = _active; final old = _active;
if (old != null && if (old != null &&
old.index == index && old.index == index &&
@@ -339,7 +359,9 @@ class PlaybackSession extends ChangeNotifier {
await _enqueue(() => _start(slot, request)); await _enqueue(() => _start(slot, request));
await Future<void>.delayed(_qualityHandoffHold); await Future<void>.delayed(_qualityHandoffHold);
if (previous != null && _handoff == previous && _active == slot) { if (previous != null && _handoff == previous && _active == slot) {
await _enqueue(() => _retire(previous)); await _enqueue(
() => _retire(previous, trigger: 'quality-handoff-complete'),
);
} }
if (!_closed && request == _generation) _warmNeighbors(this); if (!_closed && request == _generation) _warmNeighbors(this);
return _active == slot && slot.failure == null; return _active == slot && slot.failure == null;
@@ -351,8 +373,8 @@ class PlaybackSession extends ChangeNotifier {
if (_closed || request != _generation || _active == null) return; if (_closed || request != _generation || _active == null) return;
final warm = _next; final warm = _next;
_next = null; _next = null;
await _retire(_handoff); await _retire(_handoff, trigger: 'replacement-handoff');
await _retire(warm); await _retire(warm, trigger: 'replacement-next');
if (_closed || request != _generation || _active == null) return; if (_closed || request != _generation || _active == null) return;
try { try {
created = _create(index); created = _create(index);
@@ -379,7 +401,9 @@ class PlaybackSession extends ChangeNotifier {
Future<void> _dropReplacement(_PlayerSlot slot) { Future<void> _dropReplacement(_PlayerSlot slot) {
return _enqueue(() async { return _enqueue(() async {
if (_live.contains(slot)) await _retire(slot); if (_live.contains(slot)) {
await _retire(slot, trigger: 'replacement-drop');
}
}); });
} }
@@ -474,7 +498,7 @@ class PlaybackSession extends ChangeNotifier {
_loading = false; _loading = false;
_notify(); _notify();
for (final slot in _live.toList()) { for (final slot in _live.toList()) {
await _retire(slot); await _retire(slot, trigger: 'session-release-all');
} }
}); });
} }
@@ -483,6 +507,14 @@ class PlaybackSession extends ChangeNotifier {
if (!_closed) notifyListeners(); if (!_closed) notifyListeners();
} }
String _slotRole(_PlayerSlot slot, {String? roleHint}) {
if (identical(slot, _active)) return 'current';
if (identical(slot, _previous)) return 'previous';
if (identical(slot, _next)) return 'next';
if (identical(slot, _handoff)) return 'handoff';
return roleHint ?? 'unassigned';
}
void _logOwnerRejection(String phase, _PlayerSlot slot) { void _logOwnerRejection(String phase, _PlayerSlot slot) {
if (!kDebugMode) return; if (!kDebugMode) return;
debugPrint( debugPrint(
@@ -2,7 +2,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../../config/app_config.dart'; import '../../config/app_config.dart';
@@ -69,13 +68,6 @@ class VideoPlayerPort implements PlayerPort {
Future<void> dispose() async { Future<void> dispose() async {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
if (kDebugMode) {
debugPrint(
'[player-controller] phase=dispose '
'controller=${identityHashCode(controller)} '
'source=${controller.dataSource}\n${StackTrace.current}',
);
}
try { try {
await _initialization; await _initialization;
} catch (_) { } catch (_) {
+89 -9
View File
@@ -1,9 +1,11 @@
// 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';
@@ -91,6 +93,7 @@ class PlayerPage extends StatefulWidget {
this.downloads, this.downloads,
this.handoff, this.handoff,
this.revealAfterFirstFrame = false, this.revealAfterFirstFrame = false,
this.initialFrameBytes,
}); });
final Drama drama; final Drama drama;
@@ -119,6 +122,7 @@ class PlayerPage extends StatefulWidget {
final DownloadController? downloads; final DownloadController? downloads;
final PlayerPageHandoff? handoff; final PlayerPageHandoff? handoff;
final bool revealAfterFirstFrame; final bool revealAfterFirstFrame;
final Uint8List? initialFrameBytes;
@override @override
State<PlayerPage> createState() => _PlayerPageState(); State<PlayerPage> createState() => _PlayerPageState();
@@ -142,6 +146,7 @@ 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 = {};
@@ -599,10 +604,13 @@ class _PlayerPageState extends State<PlayerPage>
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
PlayerRenderStack( RepaintBoundary(
pageController: _pageController, key: _renderBoundaryKey,
slots: _renderSlots(indices), child: PlayerRenderStack(
onCurrentVideoVisible: _handleCurrentVideoVisible, pageController: _pageController,
slots: _renderSlots(indices),
onCurrentVideoVisible: _handleCurrentVideoVisible,
),
), ),
pageLayer, pageLayer,
], ],
@@ -677,7 +685,25 @@ class _PlayerPageState extends State<PlayerPage>
logic.blockedEpisodeId != null || logic.blockedEpisodeId != null ||
logic.session.error != null; logic.session.error != null;
final presentedPlayer = widget.revealAfterFirstFrame final presentedPlayer = widget.revealAfterFirstFrame
? Opacity(opacity: revealContent ? 1 : 0, child: player) ? Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: revealContent
? const SizedBox.expand()
: widget.initialFrameBytes == null
? _buildInitialFrameFallback()
: Image.memory(
widget.initialFrameBytes!,
key: const Key('player-entry-frame'),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _buildInitialFrameFallback(),
),
),
Opacity(opacity: revealContent ? 1 : 0, child: player),
],
)
: player; : player;
if (widget.handoff == null) return presentedPlayer; if (widget.handoff == null) return presentedPlayer;
return PopScope<void>( return PopScope<void>(
@@ -700,6 +726,24 @@ class _PlayerPageState extends State<PlayerPage>
} }
} }
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() { void _openCurrentMore() {
final entry = logic.queue.entryAt(logic.session.currentIndex); final entry = logic.queue.entryAt(logic.session.currentIndex);
_openMore(context, entry.episode); _openMore(context, entry.episode);
@@ -1195,6 +1239,8 @@ class _PlayerPageState extends State<PlayerPage>
seriesEpisodes.insert(0, sourceEpisode); seriesEpisodes.insert(0, sourceEpisode);
episodeIndex = 0; episodeIndex = 0;
} }
final initialFrameBytes = await _captureCurrentFrame();
if (!mounted || !context.mounted) return;
final sourceUserWantsPlay = logic.session.userWantsPlay; final sourceUserWantsPlay = logic.session.userWantsPlay;
try { try {
await logic.pauseForFullPlayer(); await logic.pauseForFullPlayer();
@@ -1207,17 +1253,20 @@ class _PlayerPageState extends State<PlayerPage>
setState(() => _presentationActive = true); setState(() => _presentationActive = true);
final route = Navigator.of(context).push<void>( final route = Navigator.of(context).push<void>(
PageRouteBuilder<void>( PageRouteBuilder<void>(
opaque: false, opaque: true,
barrierDismissible: false, barrierDismissible: false,
transitionDuration: Duration.zero, transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: Duration.zero, reverseTransitionDuration: const Duration(milliseconds: 180),
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: true, revealAfterFirstFrame: initialFrameBytes != null,
initialFrameBytes: initialFrameBytes,
), ),
), ),
); );
@@ -1230,6 +1279,37 @@ 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);
+17
View File
@@ -129,11 +129,15 @@ void main() {
expect(sourceLiveBefore, greaterThan(1)); expect(sourceLiveBefore, greaterThan(1));
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await _waitForFullPlayerEntry(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final fullPlayer = _fullPlayerFinder(); final fullPlayer = _fullPlayerFinder();
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);
expect(fullPlayerWidget.initialFrameBytes, isNotNull);
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)));
expect(feedPlayer.disposed, isFalse); expect(feedPlayer.disposed, isFalse);
@@ -221,6 +225,7 @@ void main() {
expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await _waitForFullPlayerEntry(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(_fullPlayerFinder(), findsOneWidget); expect(_fullPlayerFinder(), findsOneWidget);
@@ -263,6 +268,7 @@ void main() {
expect(feedIndex, 1); expect(feedIndex, 1);
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await _waitForFullPlayerEntry(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final fullLogic = _pageLogic(tester, _fullPlayerFinder()); final fullLogic = _pageLogic(tester, _fullPlayerFinder());
@@ -1658,6 +1664,17 @@ Finder _fullPlayerFinder() {
); );
} }
Future<void> _waitForFullPlayerEntry(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 (_fullPlayerFinder().evaluate().isNotEmpty) return;
}
fail('full 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 = [];
@@ -470,6 +470,7 @@ void main() {
await _pumpRecommend(tester); await _pumpRecommend(tester);
await tester.tap(find.byKey(const Key('home-feed-series-name'))); await tester.tap(find.byKey(const Key('home-feed-series-name')));
await _waitForFullPlayer(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(PlayerPage).last, findsOneWidget); expect(find.byType(PlayerPage).last, findsOneWidget);
expect( expect(
@@ -484,6 +485,7 @@ void main() {
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await _waitForFullPlayer(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(PlayerPage).last, findsOneWidget); expect(find.byType(PlayerPage).last, findsOneWidget);
expect( expect(
@@ -550,6 +552,17 @@ void main() {
}); });
} }
Future<void> _waitForFullPlayer(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.byType(PlayerPage).evaluate().length > 1) return;
}
fail('full player route was not pushed after frame capture');
}
Future<void> _pumpRecommend(WidgetTester tester) async { Future<void> _pumpRecommend(WidgetTester tester) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;