diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 4e8c146..41b8871 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -40,6 +40,7 @@ import 'widgets/player_center_toggle.dart'; import 'widgets/player_more_sheet.dart'; import 'widgets/player_overlay.dart'; import 'widgets/player_overlay_models.dart'; +import 'widgets/player_render_stack.dart'; import 'widgets/player_speed_sheet.dart'; import 'widgets/player_unlock_sheet.dart'; import 'widgets/player_opening_frame.dart'; @@ -528,6 +529,14 @@ class _PlayerPageState extends State itemCount: indices.length, onPageChanged: (position) { final index = indices[position]; + if (kDebugMode) { + debugPrint( + '[player-page] ' + 'ts=${DateTime.now().millisecondsSinceEpoch} ' + 'phase=session-index ' + 'previous=${logic.session.currentIndex} next=$index', + ); + } widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); unawaited(_selectPage(index)); if (position >= indices.length - 3) { @@ -535,16 +544,37 @@ class _PlayerPageState extends State if (loadMore != null) unawaited(loadMore()); } }, - itemBuilder: (context, position) => - _buildEpisode(context, indices[position], interactions), + itemBuilder: (context, position) { + final index = indices[position]; + if (kDebugMode) { + debugPrint( + '[player-page] ' + 'ts=${DateTime.now().millisecondsSinceEpoch} ' + 'phase=item-build page=$position index=$index', + ); + } + return _buildEpisode(context, index, interactions); + }, ), ); - if (widget.onRefreshFeed == null) return pages; - return FeedPullRefresh( - pageController: logic.pages, - topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap, - onRefresh: _refreshHomeFeed, - child: pages, + final pageLayer = widget.onRefreshFeed == null + ? pages + : FeedPullRefresh( + pageController: logic.pages, + topInset: + MediaQuery.paddingOf(context).top + _feedRefreshTopGap, + onRefresh: _refreshHomeFeed, + child: pages, + ); + return Stack( + fit: StackFit.expand, + children: [ + PlayerRenderStack( + pageController: logic.pages, + slots: _renderSlots(indices), + ), + pageLayer, + ], ); }, ); @@ -555,7 +585,6 @@ class _PlayerPageState extends State if (widget.homeMode && widget.showHomeFeedHeader && !_scrubbing && - _chromeVisible && !_boosting && !logic.pip && !logic.pipPreparing) @@ -616,6 +645,145 @@ class _PlayerPageState extends State _openMore(context, entry.episode); } + Episode _resolvedEpisode(int index) { + final entry = logic.queue.entryAt(index); + final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length + ? logic.sessionEpisodes[index] + : entry.episode; + final displayed = widget.homeMode + ? logic.displayEpisode(entry.drama, sessionEpisode) + : sessionEpisode; + final needsSessionThumbnails = + (displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) && + sessionEpisode.thumbnailsUrl != null; + final needsSessionPoster = + (displayed.posterUrl == null || displayed.posterUrl!.isEmpty) && + sessionEpisode.posterUrl != null; + if (!needsSessionThumbnails && !needsSessionPoster) return displayed; + return displayed.withVideoUrl( + displayed.videoUrl, + thumbnailsUrl: sessionEpisode.thumbnailsUrl, + posterUrl: sessionEpisode.posterUrl, + ); + } + + Map _renderSlots( + List indices, + ) { + final current = logic.session.currentIndex; + final previous = + logic.previousVisibleIndex(current) ?? + logic.session.previousPreloadedIndex; + final next = + logic.nextVisibleIndex(current) ?? logic.session.preloadedIndex; + return { + PlayerRenderSlotRole.previous: _renderSlot( + PlayerRenderSlotRole.previous, + previous, + indices, + currentIndex: current, + ), + PlayerRenderSlotRole.current: _renderSlot( + PlayerRenderSlotRole.current, + current, + indices, + currentIndex: current, + ), + PlayerRenderSlotRole.next: _renderSlot( + PlayerRenderSlotRole.next, + next, + indices, + currentIndex: current, + ), + }; + } + + PlayerRenderSlotData _renderSlot( + PlayerRenderSlotRole role, + int? index, + List indices, { + required int currentIndex, + }) { + if (index == null || index < 0 || index >= logic.sessionEpisodes.length) { + return PlayerRenderSlotData.empty(role); + } + final episode = _resolvedEpisode(index); + final selected = index == currentIndex; + final locked = + logic.isEpisodeLocked(episode) || + (selected && logic.session.isLocked) || + logic.blockedEpisodeId == episode.id; + final port = locked + ? null + : (selected ? logic.session.active : null) ?? + logic.session.preparedPlayerAt(index); + final videoPort = port is VideoPlayerPort ? port : null; + final handoff = selected && !locked ? logic.session.handoffAt(index) : null; + final handoffPort = handoff is VideoPlayerPort ? handoff : null; + final videoReady = videoPort?.controller.value.isInitialized ?? false; + final theater = logic.pip || logic.pipPreparing; + final videoFit = theater ? BoxFit.contain : BoxFit.cover; + final preparedFrameAvailable = + !locked && + videoReady && + (selected + ? logic.session.reusedWarmAt(index) + : logic.session.preparedPlayerAt(index) != null); + final thumbnailsUrl = episode.thumbnailsUrl; + final posterUrl = episode.posterUrl; + final posterReady = posterUrl?.trim().isNotEmpty ?? false; + final thumbnailReady = + thumbnailsUrl != null && + _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; + if (!locked && !posterReady && thumbnailsUrl != null) { + unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); + } + if (selected && + !locked && + videoPort != null && + videoPort.controller.value.isInitialized) { + unawaited( + _spriteAxisService.prepareAt( + thumbnailsUrl, + videoPort.controller.value.position, + ), + ); + } + final pagePosition = indices.indexOf(index); + return PlayerRenderSlotData( + role: role, + index: index, + pagePosition: pagePosition >= 0 ? pagePosition.toDouble() : null, + controller: locked ? null : videoPort?.controller, + videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, + theater: theater, + warmFrameAvailable: preparedFrameAvailable, + thumbnailReady: thumbnailReady, + posterReady: posterReady, + fallback: handoffPort != null + ? PlayerOpeningFallback.handoff + : playerOpeningFallback( + posterUrl: posterUrl, + thumbnailsUrl: thumbnailsUrl, + ), + warmSlotReused: logic.session.reusedWarmAt(index), + waitForPlaybackFrame: logic.session.userWantsPlay, + placeholder: locked + ? null + : handoffPort != null + ? ControllerFramePlaceholder( + controller: handoffPort.controller, + theater: theater, + ) + : playerOpeningFrame( + thumbnailsUrl, + posterUrl: posterUrl, + spriteSource: _spriteAxisService, + fit: videoFit, + ), + ); + } + Widget _buildEpisode( BuildContext context, int index, @@ -624,25 +792,7 @@ class _PlayerPageState extends State final l10n = AppLocalizations.of(context)!; final entry = logic.queue.entryAt(index); final drama = entry.drama; - final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length - ? logic.sessionEpisodes[index] - : entry.episode; - final displayed = widget.homeMode - ? logic.displayEpisode(drama, sessionEpisode) - : sessionEpisode; - final needsSessionThumbnails = - (displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) && - sessionEpisode.thumbnailsUrl != null; - final needsSessionPoster = - (displayed.posterUrl == null || displayed.posterUrl!.isEmpty) && - sessionEpisode.posterUrl != null; - final episode = needsSessionThumbnails || needsSessionPoster - ? displayed.withVideoUrl( - displayed.videoUrl, - thumbnailsUrl: sessionEpisode.thumbnailsUrl, - posterUrl: sessionEpisode.posterUrl, - ) - : displayed; + final episode = _resolvedEpisode(index); final selected = index == logic.session.currentIndex; final locked = logic.isEpisodeLocked(episode) || @@ -655,79 +805,15 @@ class _PlayerPageState extends State : (selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; - final handoff = selected && !locked ? logic.session.handoffAt(index) : null; - final handoffPort = handoff is VideoPlayerPort ? handoff : null; // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 final videoReady = videoPort?.controller.value.isInitialized ?? false; if (selected && videoReady) _logSwipeReveal(); final ready = !locked && (videoReady || port != null); final theater = logic.pip || logic.pipPreparing; - final videoFit = theater ? BoxFit.contain : BoxFit.cover; - final preparedFrameAvailable = - !locked && - videoReady && - (selected - ? logic.session.reusedWarmAt(index) - : logic.session.preparedPlayerAt(index) != null); - final warmSlotReused = logic.session.reusedWarmAt(index); - final thumbnailsUrl = episode.thumbnailsUrl; - final posterUrl = episode.posterUrl; - final posterReady = posterUrl?.trim().isNotEmpty ?? false; - final openingFrameReady = - thumbnailsUrl != null && - _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; - if (!posterReady && thumbnailsUrl != null) { - unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); - } - if (selected && - !locked && - videoPort != null && - videoPort.controller.value.isInitialized) { - unawaited( - _spriteAxisService.prepareAt( - episode.thumbnailsUrl, - videoPort.controller.value.position, - ), - ); - } return Stack( fit: StackFit.expand, children: [ - if (widget.homeMode) - const ColoredBox( - key: Key('home-feed-black-background'), - color: AppColors.mediaBlack, - ), - PlayerVideoSurface( - controller: locked ? null : videoPort?.controller, - videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, - theater: theater, - warmFrameAvailable: preparedFrameAvailable, - warmSlotReused: warmSlotReused, - thumbnailReady: openingFrameReady, - posterReady: posterReady, - fallback: handoffPort != null - ? PlayerOpeningFallback.handoff - : playerOpeningFallback( - posterUrl: posterUrl, - thumbnailsUrl: thumbnailsUrl, - ), - waitForPlaybackFrame: logic.session.userWantsPlay, - placeholder: locked - ? null - : handoffPort != null - ? ControllerFramePlaceholder( - controller: handoffPort.controller, - theater: theater, - ) - : playerOpeningFrame( - thumbnailsUrl, - posterUrl: posterUrl, - spriteSource: _spriteAxisService, - fit: videoFit, - ), - ), if (selected && !locked && !theater && diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart new file mode 100644 index 0000000..2f4dc29 --- /dev/null +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -0,0 +1,203 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/theme/app_colors.dart'; +import 'player_opening_frame.dart'; +import 'player_video_surface.dart'; + +enum PlayerRenderSlotRole { previous, current, next } + +class PlayerRenderSlotData { + const PlayerRenderSlotData({ + required this.role, + this.index, + this.pagePosition, + this.controller, + this.videoUrl = '', + this.theater = false, + this.placeholder, + this.warmFrameAvailable = false, + this.thumbnailReady = false, + this.posterReady = false, + this.fallback = PlayerOpeningFallback.black, + this.warmSlotReused = false, + this.waitForPlaybackFrame = false, + }); + + final PlayerRenderSlotRole role; + final int? index; + final double? pagePosition; + final VideoPlayerController? controller; + final String videoUrl; + final bool theater; + final Widget? placeholder; + final bool warmFrameAvailable; + final bool thumbnailReady; + final bool posterReady; + final PlayerOpeningFallback fallback; + final bool warmSlotReused; + final bool waitForPlaybackFrame; + + factory PlayerRenderSlotData.empty(PlayerRenderSlotRole role) { + return PlayerRenderSlotData(role: role); + } +} + +/// 播放画面不随 PageView 生命周期销毁,三个固定槽位只更新控制器和位置。 +class PlayerRenderStack extends StatefulWidget { + const PlayerRenderStack({ + super.key, + required this.pageController, + required this.slots, + }); + + final PageController pageController; + final Map slots; + + @override + State createState() => _PlayerRenderStackState(); +} + +class _PlayerRenderStackState extends State { + int? _lastCurrentIndex; + + @override + void initState() { + super.initState(); + _lastCurrentIndex = _currentSlot.index; + _log('init current=${_lastCurrentIndex ?? '-'}'); + } + + @override + void didUpdateWidget(covariant PlayerRenderStack oldWidget) { + super.didUpdateWidget(oldWidget); + final currentIndex = _currentSlot.index; + if (_lastCurrentIndex != currentIndex) { + _log( + 'session-index previous=${_lastCurrentIndex ?? '-'} ' + 'current=${currentIndex ?? '-'}', + ); + _lastCurrentIndex = currentIndex; + } + } + + @override + void dispose() { + _log('dispose current=${_lastCurrentIndex ?? '-'}'); + super.dispose(); + } + + PlayerRenderSlotData get _currentSlot => + widget.slots[PlayerRenderSlotRole.current] ?? + PlayerRenderSlotData.empty(PlayerRenderSlotRole.current); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: AnimatedBuilder( + animation: widget.pageController, + builder: (context, _) { + return LayoutBuilder( + builder: (context, constraints) { + final viewportHeight = constraints.maxHeight.isFinite + ? constraints.maxHeight + : MediaQuery.sizeOf(context).height; + final pageOffset = _pageOffset(); + final currentPage = _currentSlot.pagePosition ?? pageOffset; + final slots = [ + _slotData(PlayerRenderSlotRole.previous), + _slotData(PlayerRenderSlotRole.current), + _slotData(PlayerRenderSlotRole.next), + ]; + return Stack( + key: const Key('player-render-stack'), + fit: StackFit.expand, + clipBehavior: Clip.hardEdge, + children: [ + const ColoredBox( + key: Key('player-fallback-background'), + color: AppColors.mediaBlack, + ), + for (final slot in slots) + _positionedSurface( + slot, + pageOffset: pageOffset, + viewportHeight: viewportHeight, + fallbackPage: _fallbackPage(slot.role, currentPage), + ), + ], + ); + }, + ); + }, + ), + ); + } + + PlayerRenderSlotData _slotData(PlayerRenderSlotRole role) { + return widget.slots[role] ?? PlayerRenderSlotData.empty(role); + } + + double _pageOffset() { + if (!widget.pageController.hasClients) { + return widget.pageController.initialPage.toDouble(); + } + final position = widget.pageController.position; + if (!position.hasPixels || + !position.hasViewportDimension || + position.viewportDimension <= 0) { + return widget.pageController.initialPage.toDouble(); + } + return position.pixels / position.viewportDimension; + } + + double _fallbackPage(PlayerRenderSlotRole role, double currentPage) { + return switch (role) { + PlayerRenderSlotRole.previous => currentPage - 1, + PlayerRenderSlotRole.current => currentPage, + PlayerRenderSlotRole.next => currentPage + 1, + }; + } + + Widget _positionedSurface( + PlayerRenderSlotData slot, { + required double pageOffset, + required double viewportHeight, + required double fallbackPage, + }) { + final slotPage = slot.pagePosition ?? fallbackPage; + final offset = (slotPage - pageOffset) * viewportHeight; + return Positioned.fill( + child: Transform.translate( + key: Key('player-render-transform-${slot.role.name}'), + offset: Offset(0, offset), + child: PlayerVideoSurface( + key: ValueKey('player-render-slot-${slot.role.name}'), + debugSlot: slot.role.name, + debugIndex: slot.index, + showFallbackBackground: false, + controller: slot.controller, + videoUrl: slot.videoUrl, + theater: slot.theater, + placeholder: slot.placeholder, + warmFrameAvailable: slot.warmFrameAvailable, + thumbnailReady: slot.thumbnailReady, + posterReady: slot.posterReady, + fallback: slot.fallback, + warmSlotReused: slot.warmSlotReused, + waitForPlaybackFrame: slot.waitForPlaybackFrame, + ), + ), + ); + } + + void _log(String message) { + if (!kDebugMode) return; + debugPrint( + '[player-render] ts=${DateTime.now().millisecondsSinceEpoch} $message', + ); + } +} diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index ec4ca86..87884a7 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -25,6 +25,9 @@ class PlayerVideoSurface extends StatefulWidget { this.fallback = PlayerOpeningFallback.black, this.warmSlotReused = false, this.waitForPlaybackFrame = false, + this.debugSlot = 'default', + this.debugIndex, + this.showFallbackBackground = true, }); final VideoPlayerController? controller; @@ -49,6 +52,9 @@ class PlayerVideoSurface extends StatefulWidget { /// 自动播放中的新集等待 position 首次前进后再移交画面。 final bool waitForPlaybackFrame; + final String debugSlot; + final int? debugIndex; + final bool showFallbackBackground; final String videoUrl; final bool theater; @@ -79,8 +85,11 @@ class _PlayerVideoSurfaceState extends State final value = widget.controller?.value; _updatePlaybackFlags(value); _videoSize = value != null && value.isInitialized ? value.size : null; + _logSlot('init', value); if (_firstFrameConfirmed(value)) { _confirmFirstFrame(initial: true); + } else { + _logLayer('placeholder', value); } } @@ -88,13 +97,19 @@ class _PlayerVideoSurfaceState extends State void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { + _logSlot('controller-change-before', oldWidget.controller?.value); _detachController(oldWidget.controller); _resetForControllerChange(); _attachController(widget.controller); final value = widget.controller?.value; _updatePlaybackFlags(value); _videoSize = value != null && value.isInitialized ? value.size : null; - if (_firstFrameConfirmed(value)) _confirmFirstFrame(initial: true); + _logSlot('controller-change-after', value); + if (_firstFrameConfirmed(value)) { + _confirmFirstFrame(initial: true); + } else { + _logLayer('placeholder', value); + } return; } if (!_transitioning && @@ -119,6 +134,7 @@ class _PlayerVideoSurfaceState extends State @override void dispose() { + _logSlot('dispose', widget.controller?.value); _detachController(widget.controller); _crossFade ..removeStatusListener(_handleFadeStatus) @@ -143,10 +159,11 @@ class _PlayerVideoSurfaceState extends State return Stack( fit: StackFit.expand, children: [ - const ColoredBox( - key: Key('player-fallback-background'), - color: AppColors.mediaBlack, - ), + if (widget.showFallbackBackground) + const ColoredBox( + key: Key('player-fallback-background'), + color: AppColors.mediaBlack, + ), if (showPlaceholder) KeyedSubtree( key: const Key('player-single-placeholder'), @@ -210,14 +227,16 @@ class _PlayerVideoSurfaceState extends State _confirmationScheduled = true; if (kDebugMode) { debugPrint( - '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} ' + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} ' + 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' 'phase=first-frame-scheduled', ); } WidgetsBinding.instance.addPostFrameCallback((_) { if (kDebugMode) { debugPrint( - '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} ' + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} ' + 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' 'phase=first-frame-callback mounted=$mounted', ); } @@ -246,6 +265,7 @@ class _PlayerVideoSurfaceState extends State void _confirmFirstFrame({bool initial = false}) { if (_frameConfirmed || _videoSize == null) return; _frameConfirmed = true; + _logSlot('firstFrameRendered', widget.controller?.value); final canSkipPlaceholder = widget.warmFrameAvailable || _placeholderSnapshot == null; if (canSkipPlaceholder) { @@ -303,6 +323,8 @@ class _PlayerVideoSurfaceState extends State ? 'video' : _fallbackLabel(widget.fallback); final signature = + 'slot=${widget.debugSlot} ' + 'index=${widget.debugIndex ?? '-'} ' 'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} ' 'reusedWarm=${widget.warmSlotReused ? 1 : 0} ' 'frame=${_frameConfirmed ? 1 : 0} ' @@ -312,14 +334,34 @@ class _PlayerVideoSurfaceState extends State 'init=${value?.isInitialized == true ? 1 : 0} ' 'position=${value?.position.inMilliseconds ?? 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' - 'buffering=${value?.isBuffering == true ? 1 : 0}'; + 'buffering=${value?.isBuffering == true ? 1 : 0} ' + 'placeholder=${_placeholderLabel()}'; if (signature == _lastLayerState) return; _lastLayerState = signature; debugPrint( - '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature', + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} $signature', ); } + void _logSlot(String phase, VideoPlayerValue? value) { + if (!kDebugMode) return; + debugPrint( + '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} ' + 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' + 'phase=$phase init=${value?.isInitialized == true ? 1 : 0}', + ); + } + + String _placeholderLabel() { + if (_placeholderSnapshot == null) return 'none'; + return switch (widget.fallback) { + PlayerOpeningFallback.handoff => 'handoff', + PlayerOpeningFallback.poster => 'poster', + PlayerOpeningFallback.thumbnails => 'thumbnails', + PlayerOpeningFallback.black => 'none', + }; + } + String _fallbackLabel(PlayerOpeningFallback fallback) { if (_placeholderSnapshot == null) return 'black'; return switch (fallback) { diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 7704bfc..68d6622 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -55,6 +55,19 @@ void main() { pageViews.where((pageView) => pageView.scrollDirection == Axis.vertical), hasLength(1), ); + expect(find.byKey(const Key('player-render-stack')), findsOneWidget); + final renderStackPageAncestors = tester.widgetList( + find.ancestor( + of: find.byKey(const Key('player-render-stack')), + matching: find.byType(PageView), + ), + ); + expect( + renderStackPageAncestors.where( + (pageView) => pageView.scrollDirection == Axis.vertical, + ), + isEmpty, + ); expect(tester.takeException(), isNull); }); diff --git a/test/player/player_render_stack_test.dart b/test/player/player_render_stack_test.dart new file mode 100644 index 0000000..ddd7d0a --- /dev/null +++ b/test/player/player_render_stack_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/pages/player/widgets/player_render_stack.dart'; +import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; +import 'package:video_player/video_player.dart'; + +void main() { + testWidgets('should_keep_slots_alive_and_only_translate_during_page_change', ( + tester, + ) async { + final pages = PageController(initialPage: 1); + final previousController = _controller('previous'); + final currentController = _controller('current'); + final nextController = _controller('next'); + addTearDown(pages.dispose); + addTearDown(previousController.dispose); + addTearDown(currentController.dispose); + addTearDown(nextController.dispose); + + var currentIndex = 1; + late StateSetter rebuild; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return Stack( + fit: StackFit.expand, + children: [ + PlayerRenderStack( + pageController: pages, + slots: _slots( + currentIndex: currentIndex, + previousController: previousController, + currentController: currentController, + nextController: nextController, + ), + ), + PageView( + controller: pages, + children: const [ + SizedBox.expand(key: Key('page-0')), + SizedBox.expand(key: Key('page-1')), + SizedBox.expand(key: Key('page-2')), + ], + ), + ], + ); + }, + ), + ), + ), + ); + await tester.pump(); + + final currentSlot = find.byKey( + const ValueKey('player-render-slot-current'), + ); + final currentState = tester.state(currentSlot); + final currentElement = tester.element(currentSlot); + final currentSurface = tester.widget(currentSlot); + expect(currentSurface.controller, same(currentController)); + expect(_translationY(tester, 'current'), 0); + expect(_translationY(tester, 'next'), greaterThan(0)); + + pages.jumpToPage(2); + await tester.pump(); + await tester.pump(); + + expect(tester.state(currentSlot), same(currentState)); + expect(tester.element(currentSlot), same(currentElement)); + expect( + tester.widget(currentSlot).controller, + same(currentController), + ); + expect(_translationY(tester, 'current'), lessThan(0)); + expect(_translationY(tester, 'next'), 0); + + rebuild(() => currentIndex = 2); + await tester.pump(); + + expect(tester.state(currentSlot), same(currentState)); + expect(tester.element(currentSlot), same(currentElement)); + expect(_translationY(tester, 'current'), 0); + expect(_translationY(tester, 'previous'), lessThan(0)); + expect( + find.descendant( + of: find.byType(PlayerRenderStack), + matching: find.byType(IgnorePointer), + ), + findsOneWidget, + ); + }); +} + +Map _slots({ + required int currentIndex, + required VideoPlayerController previousController, + required VideoPlayerController currentController, + required VideoPlayerController nextController, +}) { + return { + PlayerRenderSlotRole.previous: PlayerRenderSlotData( + role: PlayerRenderSlotRole.previous, + index: currentIndex - 1, + pagePosition: (currentIndex - 1).toDouble(), + controller: previousController, + placeholder: const ColoredBox( + key: Key('previous-placeholder'), + color: Colors.red, + ), + ), + PlayerRenderSlotRole.current: PlayerRenderSlotData( + role: PlayerRenderSlotRole.current, + index: currentIndex, + pagePosition: currentIndex.toDouble(), + controller: currentController, + placeholder: const ColoredBox( + key: Key('current-placeholder'), + color: Colors.green, + ), + ), + PlayerRenderSlotRole.next: PlayerRenderSlotData( + role: PlayerRenderSlotRole.next, + index: currentIndex + 1, + pagePosition: (currentIndex + 1).toDouble(), + controller: nextController, + placeholder: const ColoredBox( + key: Key('next-placeholder'), + color: Colors.blue, + ), + ), + }; +} + +VideoPlayerController _controller(String name) { + return VideoPlayerController.networkUrl( + Uri.parse('https://example.test/$name.mp4'), + ); +} + +double _translationY(WidgetTester tester, String role) { + final transform = tester.widget( + find.byKey(Key('player-render-transform-$role')), + ); + return transform.transform.getTranslation().y; +}