diff --git a/lib/pages/player/player_frame_capture.dart b/lib/pages/player/player_frame_capture.dart deleted file mode 100644 index 41e7b18..0000000 --- a/lib/pages/player/player_frame_capture.dart +++ /dev/null @@ -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? provider; - - bool get isReady => bytes != null || provider != null; -} - -class PlayerFrameCapture { - const PlayerFrameCapture._(); - - static const int budgetMilliseconds = 32; - - static Future 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 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 _precache( - BuildContext context, - ImageProvider 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); - } -} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index a9db4d2..ac76aa9 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -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 Function() _waitForFrame; late PageController pages; - final GlobalKey renderBoundaryKey = GlobalKey( - debugLabel: 'player-render-boundary', - ); late PlaybackQueue queue; late List 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( 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; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index ee22e73..f89976c 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -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? initialFrameProvider; @override State createState() => _PlayerPageState(); @@ -146,16 +140,13 @@ class _PlayerPageState extends State 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 _entryElementStates = {}; + int? _loadingIndex; + int? _videoReadyIndex; + bool _loadingVisible = false; + bool _loadingRemoved = true; + bool _loadingFadeScheduled = false; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -165,14 +156,6 @@ class _PlayerPageState extends State @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(), @@ -196,11 +179,10 @@ class _PlayerPageState extends State 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 final interactions = context.watch(); 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 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 ), ), ), + if (!widget.embedded && !_loadingRemoved) + Positioned.fill( + child: PlayerLoadingOverlay( + key: ValueKey('player-loading-${_loadingIndex ?? currentIndex}'), + opacity: _loadingVisible ? 1 : 0, + onFadeEnd: _handleLoadingFadeEnd, + ), + ), ], ); final player = AnnotatedRegion( @@ -692,78 +706,25 @@ class _PlayerPageState extends State ), ), ); - _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( 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 } 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 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 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 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 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; diff --git a/lib/pages/player/widgets/player_loading_overlay.dart b/lib/pages/player/widgets/player_loading_overlay.dart new file mode 100644 index 0000000..eb890a3 --- /dev/null +++ b/lib/pages/player/widgets/player_loading_overlay.dart @@ -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, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart index 4cd02f5..28489dd 100644 --- a/lib/pages/player/widgets/player_render_stack.dart +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -58,7 +58,7 @@ class PlayerRenderStack extends StatefulWidget { final PageController pageController; final Map slots; - final VoidCallback? onCurrentVideoVisible; + final ValueChanged? onCurrentVideoVisible; final bool showNeighbors; @override @@ -202,7 +202,7 @@ class _PlayerRenderStackState extends State { warmSlotReused: slot.warmSlotReused, waitForPlaybackFrame: slot.waitForPlaybackFrame, onVideoVisible: slot.role == PlayerRenderSlotRole.current - ? widget.onCurrentVideoVisible + ? () => widget.onCurrentVideoVisible?.call(slot.index) : null, ), ), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index dff4092..dd45c1d 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -129,30 +129,32 @@ 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 != null || - fullPlayerWidget.initialFrameProvider != null, - isTrue, - ); - expect(fullPlayerWidget.revealAfterFirstFrame, isTrue); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller))); expect(feedPlayer.disposed, isFalse); expect(feedPlayer.playing, isFalse); + final fullPlayerPage = find + .descendant( + of: _fullPlayerFinder(), + matching: find.byWidgetPredicate( + (widget) => + widget is PageView && widget.scrollDirection == Axis.vertical, + ), + ) + .first; for (var index = 0; index < 4; index++) { await tester.drag( - find.byKey(const Key('player-video-tap')), + fullPlayerPage, const Offset(0, -560), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); } expect(fullLogic.session.currentEpisode.number, 5); @@ -168,7 +170,8 @@ void main() { expect(feedPlayer.disposed, isFalse); final requestsBeforeReturn = playback.requests.length; - await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-back')).hitTestable()); await tester.pumpAndSettle(); await sourceLogic.session.preloadSettled; await tester.pumpAndSettle(); @@ -229,7 +232,6 @@ 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); @@ -250,36 +252,109 @@ void main() { expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); - testWidgets('should_capture_feed_frame_when_opening_series_from_picker', ( + testWidgets('should_show_non_blocking_loading_when_opening_series', ( tester, ) async { - final factory = _RouteVideoFactory(); - await _pumpApp(tester, playerFactory: factory.create); + final initialization = Completer(); + final routeFactory = _RouteVideoFactory(); + await _pumpApp( + tester, + playerFactory: (episode) => episode.id == 'the-signal-demo-2' + ? _GatedPlayer(initialization.future) + : routeFactory.create(episode), + ); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-episodes'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-episode-2'))); - await _waitForRevealedPlayerEntry(tester); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); - final player = find.byWidgetPredicate( - (widget) => widget is PlayerPage && widget.revealAfterFirstFrame, + final loadingOverlay = find.byKey( + const Key('player-entry-loading-overlay'), ); - expect(player, findsOneWidget); - final playerWidget = tester.widget(player); + final fullPlayer = find.byType(PlayerPage).last; + final fullLogic = _pageLogic(tester, fullPlayer); + final fullPlayerPage = find + .descendant( + of: fullPlayer, + matching: find.byWidgetPredicate( + (widget) => + widget is PageView && widget.scrollDirection == Axis.vertical, + ), + ) + .first; + expect(loadingOverlay, findsOneWidget); + expect(tester.widget(loadingOverlay).ignoring, isTrue); expect( - playerWidget.initialFrameBytes != null || - playerWidget.initialFrameProvider != null, - isTrue, + tester + .widget( + find.descendant( + of: loadingOverlay, + matching: find.byType(AnimatedOpacity), + ), + ) + .duration, + const Duration(milliseconds: 140), ); - expect(playerWidget.revealAfterFirstFrame, isTrue); - expect(playerWidget.initialIndex, 1); + expect( + tester.getSize(find.byKey(const Key('player-loading-surface'))), + const Size(40, 40), + ); + expect( + tester.getSize(find.byKey(const Key('player-entry-loading'))), + const Size(20, 20), + ); + expect( + tester + .widget( + find.byKey(const Key('player-entry-loading')), + ) + .color, + Colors.white, + ); + expect( + tester + .widget( + find.descendant( + of: loadingOverlay, + matching: find.byType(ColoredBox), + ), + ) + .color, + AppColors.mediaBlack, + ); + final loadingSurface = tester.widget( + find.byKey(const Key('player-loading-surface')), + ); + final decoration = loadingSurface.decoration! as BoxDecoration; + expect(decoration.color, AppColors.refreshIndicatorSurface); + expect(decoration.borderRadius, BorderRadius.circular(20)); - await tester.tap(find.byKey(const Key('player-back'))); + // IgnorePointer 让加载中的播放暂停与上下滑仍能透传。 + expect(fullLogic.session.userWantsPlay, isTrue); + await tester.tap(find.byKey(const Key('player-video-tap')).hitTestable()); + await tester.pump(); + expect(fullLogic.session.userWantsPlay, isFalse); + final indexBeforeSwipe = fullLogic.session.currentIndex; + await tester.drag( + fullPlayerPage, + const Offset(0, -560), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(fullLogic.session.currentIndex, isNot(indexBeforeSwipe)); + + // IgnorePointer 让加载中的返回按钮仍可点击。 + await tester.pumpAndSettle(); + expect(loadingOverlay, findsNothing); + await tester.tap(find.byKey(const Key('player-back')).hitTestable()); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); + initialization.complete(); + await tester.pump(); }); testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async { @@ -304,7 +379,6 @@ 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()); @@ -1700,35 +1774,6 @@ 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'); -} - -Future _waitForRevealedPlayerEntry(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 - .byWidgetPredicate( - (widget) => widget is PlayerPage && widget.revealAfterFirstFrame, - ) - .evaluate() - .isNotEmpty) { - return; - } - } - fail('revealed 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 dde01b8..c39412e 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -470,7 +470,6 @@ 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( @@ -485,7 +484,6 @@ 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( @@ -552,24 +550,6 @@ 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 - .byWidgetPredicate( - (widget) => widget is PlayerPage && !widget.homeMode, - ) - .evaluate() - .isNotEmpty) { - 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;