diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index bed6800..2d54d46 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -59,12 +59,16 @@ Future _parkNeighbors( _PlayerSlot? warmNext, _PlayerSlot? warmPrevious, ) async { - await _placeNeighbor(session, old); - await _placeNeighbor(session, warmNext); - await _placeNeighbor(session, warmPrevious); + await _placeNeighbor(session, old, roleHint: 'old-current'); + await _placeNeighbor(session, warmNext, roleHint: 'next'); + await _placeNeighbor(session, warmPrevious, roleHint: 'previous'); } -Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { +Future _placeNeighbor( + PlaybackSession session, + _PlayerSlot? slot, { + required String roleHint, +}) async { if (slot == null || slot == session._active || slot.retiring) return; if (slot.owner != session.owner) { session._logOwnerRejection('park', slot); @@ -78,7 +82,7 @@ Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { session._previous = slot; return; } - await session._retire(slot); + await session._retire(slot, trigger: 'park-neighbor', roleHint: roleHint); } /// 当前集已经出画后,按手势方向静默准备上一集和下一集。 @@ -118,7 +122,11 @@ Future _warmOne( await session._enqueue(() async { if (!_stillHeld(session, candidate, next: next)) return; _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; if (!allowed || current?.index != index) { _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)) { return; diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 4637550..205a6cd 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -138,8 +138,12 @@ class PlaybackSession extends ChangeNotifier { _notify(); unawaited( _enqueue(() async { - await _retire(staleNext); - await _retire(stalePrevious); + await _retire(staleNext, trigger: 'remap-stale-next', roleHint: 'next'); + await _retire( + stalePrevious, + trigger: 'remap-stale-previous', + roleHint: 'previous', + ); }), ); if (_visible && !_overlayOpen) _warmNeighbors(this); @@ -190,20 +194,23 @@ class PlaybackSession extends ChangeNotifier { return slot; } - Future _retire(_PlayerSlot? slot) async { + Future _retire( + _PlayerSlot? slot, { + required String trigger, + String? roleHint, + }) async { if (slot == null) return; if (slot.owner != owner) { _logOwnerRejection('retire', slot); return; } if (!_live.contains(slot)) return; - if (kDebugMode) { - debugPrint( - '[player-owner] phase=retire owner=${slot.owner.name} ' - 'drama=${slot.dramaId ?? '-'} episodeIndex=${slot.index} ' - 'controller=${_playerIdentity(slot.player)}', - ); - } + final role = _slotRole(slot, roleHint: roleHint); + final video = slot.player is VideoPlayerPort + ? slot.player as VideoPlayerPort + : null; + final value = video?.controller.value; + final slotPlaying = slot.playing; slot.retiring = true; final cleared = _active == slot || @@ -222,6 +229,19 @@ class PlaybackSession extends ChangeNotifier { } finally { await slot.player.dispose(); _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; await _enqueue(() async { if (_closed || request != _generation) return; - await _retire(_handoff); + await _retire(_handoff, trigger: 'select-handoff'); final old = _active; if (old != null && old.index == index && @@ -339,7 +359,9 @@ class PlaybackSession extends ChangeNotifier { await _enqueue(() => _start(slot, request)); await Future.delayed(_qualityHandoffHold); 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); return _active == slot && slot.failure == null; @@ -351,8 +373,8 @@ class PlaybackSession extends ChangeNotifier { if (_closed || request != _generation || _active == null) return; final warm = _next; _next = null; - await _retire(_handoff); - await _retire(warm); + await _retire(_handoff, trigger: 'replacement-handoff'); + await _retire(warm, trigger: 'replacement-next'); if (_closed || request != _generation || _active == null) return; try { created = _create(index); @@ -379,7 +401,9 @@ class PlaybackSession extends ChangeNotifier { Future _dropReplacement(_PlayerSlot slot) { 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; _notify(); 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(); } + 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) { if (!kDebugMode) return; debugPrint( diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index 032378a..2cb2b1f 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -2,7 +2,6 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:video_player/video_player.dart'; import '../../config/app_config.dart'; @@ -69,13 +68,6 @@ class VideoPlayerPort implements PlayerPort { Future dispose() async { if (_disposed) return; _disposed = true; - if (kDebugMode) { - debugPrint( - '[player-controller] phase=dispose ' - 'controller=${identityHashCode(controller)} ' - 'source=${controller.dataSource}\n${StackTrace.current}', - ); - } try { await _initialization; } catch (_) { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index fd5b85f..451c720 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1,9 +1,11 @@ // Author: fruit.com.cn import 'dart:async'; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -91,6 +93,7 @@ class PlayerPage extends StatefulWidget { this.downloads, this.handoff, this.revealAfterFirstFrame = false, + this.initialFrameBytes, }); final Drama drama; @@ -119,6 +122,7 @@ class PlayerPage extends StatefulWidget { final DownloadController? downloads; final PlayerPageHandoff? handoff; final bool revealAfterFirstFrame; + final Uint8List? initialFrameBytes; @override State createState() => _PlayerPageState(); @@ -142,6 +146,7 @@ class _PlayerPageState extends State bool _swipePrewarmCaptured = false; bool _presentationActive = false; late bool _firstFrameReady; + final GlobalKey _renderBoundaryKey = GlobalKey(); bool _leavingHandoff = false; bool _preparingFullPlayer = false; final Set _precachedPosterUrls = {}; @@ -599,10 +604,13 @@ class _PlayerPageState extends State return Stack( fit: StackFit.expand, children: [ - PlayerRenderStack( - pageController: _pageController, - slots: _renderSlots(indices), - onCurrentVideoVisible: _handleCurrentVideoVisible, + RepaintBoundary( + key: _renderBoundaryKey, + child: PlayerRenderStack( + pageController: _pageController, + slots: _renderSlots(indices), + onCurrentVideoVisible: _handleCurrentVideoVisible, + ), ), pageLayer, ], @@ -677,7 +685,25 @@ class _PlayerPageState extends State logic.blockedEpisodeId != null || logic.session.error != null; 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; if (widget.handoff == null) return presentedPlayer; return PopScope( @@ -700,6 +726,24 @@ class _PlayerPageState extends State } } + 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); @@ -1195,6 +1239,8 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } + final initialFrameBytes = await _captureCurrentFrame(); + if (!mounted || !context.mounted) return; final sourceUserWantsPlay = logic.session.userWantsPlay; try { await logic.pauseForFullPlayer(); @@ -1207,17 +1253,20 @@ class _PlayerPageState extends State setState(() => _presentationActive = true); final route = Navigator.of(context).push( PageRouteBuilder( - opaque: false, + opaque: true, barrierDismissible: false, - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, + transitionDuration: const Duration(milliseconds: 180), + reverseTransitionDuration: const Duration(milliseconds: 180), + transitionsBuilder: (_, animation, _, child) => + FadeTransition(opacity: animation, child: child), pageBuilder: (_, _, _) => PlayerPage( drama: seriesDrama, initialIndex: episodeIndex, handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, - revealAfterFirstFrame: true, + revealAfterFirstFrame: initialFrameBytes != null, + initialFrameBytes: initialFrameBytes, ), ), ); @@ -1230,6 +1279,37 @@ class _PlayerPageState extends State _logFullPlayerRoute('restored'); } + Future _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 _leaveHandoff(BuildContext context) async { if (_leavingHandoff) return; setState(() => _leavingHandoff = true); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 131e698..62d2105 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -129,11 +129,15 @@ void main() { expect(sourceLiveBefore, greaterThan(1)); await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await _waitForFullPlayerEntry(tester); await tester.pumpAndSettle(); final fullPlayer = _fullPlayerFinder(); final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); + final fullPlayerWidget = tester.widget(fullPlayer); + expect(fullPlayerWidget.initialFrameBytes, isNotNull); + expect(fullPlayerWidget.revealAfterFirstFrame, isTrue); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller))); expect(feedPlayer.disposed, isFalse); @@ -221,6 +225,7 @@ void main() { expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await _waitForFullPlayerEntry(tester); await tester.pumpAndSettle(); expect(_fullPlayerFinder(), findsOneWidget); @@ -263,6 +268,7 @@ void main() { expect(feedIndex, 1); await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await _waitForFullPlayerEntry(tester); await tester.pumpAndSettle(); final fullLogic = _pageLogic(tester, _fullPlayerFinder()); @@ -1658,6 +1664,17 @@ Finder _fullPlayerFinder() { ); } +Future _waitForFullPlayerEntry(WidgetTester tester) async { + for (var attempt = 0; attempt < 120; attempt++) { + await tester.runAsync( + () => Future.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 { final List<_RouteVideoPlayer> created = []; final List episodes = []; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index c39412e..3f7e221 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -470,6 +470,7 @@ void main() { await _pumpRecommend(tester); await tester.tap(find.byKey(const Key('home-feed-series-name'))); + await _waitForFullPlayer(tester); await tester.pumpAndSettle(); expect(find.byType(PlayerPage).last, findsOneWidget); expect( @@ -484,6 +485,7 @@ void main() { expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await _waitForFullPlayer(tester); await tester.pumpAndSettle(); expect(find.byType(PlayerPage).last, findsOneWidget); expect( @@ -550,6 +552,17 @@ void main() { }); } +Future _waitForFullPlayer(WidgetTester tester) async { + for (var attempt = 0; attempt < 120; attempt++) { + await tester.runAsync( + () => Future.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 _pumpRecommend(WidgetTester tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1;