// 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, this.onCurrentVideoVisible, }); final PageController pageController; final Map slots; final VoidCallback? onCurrentVideoVisible; @override State createState() => _PlayerRenderStackState(); } class _PlayerRenderStackState extends State { int? _lastCurrentIndex; @override void initState() { super.initState(); _lastCurrentIndex = _currentSlot.index; _log( 'init current=${_lastCurrentIndex ?? '-'} ' 'pageController=${identityHashCode(widget.pageController)}', ); } @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 ?? '-'} ' 'pageController=${identityHashCode(widget.pageController)}', ); 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, onVideoVisible: slot.role == PlayerRenderSlotRole.current ? widget.onCurrentVideoVisible : null, ), ), ); } void _log(String message) { if (!kDebugMode) return; debugPrint( '[player-render] ts=${DateTime.now().millisecondsSinceEpoch} $message', ); } }