// 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'; const Duration _crossFadeDuration = Duration(milliseconds: 180); const double _errorHorizontal = 32; const int _errorMaxLines = 3; /// 播放区只维护一个占位和一个视频层,首帧确认后执行一次交叉淡化。 class PlayerVideoSurface extends StatefulWidget { const PlayerVideoSurface({ super.key, required this.controller, required this.videoUrl, required this.theater, this.placeholder, this.warmFrameAvailable = false, this.thumbnailReady = false, this.posterReady = false, this.fallback = PlayerOpeningFallback.black, this.warmSlotReused = false, this.waitForPlaybackFrame = false, this.debugSlot = 'default', this.debugIndex, this.showFallbackBackground = true, this.onVideoVisible, this.resumeSignal = 0, }); final VideoPlayerController? controller; /// 视频首帧未确认时唯一的占位 widget。 final Widget? placeholder; /// 相邻集预热槽已可用时直接出视频,不挂载占位。 final bool warmFrameAvailable; /// 当前集 t=0 雪碧图是否已命中缓存,仅用于调试判定链。 final bool thumbnailReady; /// 当前集高清 poster 是否已下发,仅用于调试判定链。 final bool posterReady; /// 首个视频帧确认前实际选择的唯一占位来源。 final PlayerOpeningFallback fallback; /// 当前集是否为相邻集预热槽晋升而来。 final bool warmSlotReused; /// 自动播放中的新集等待 position 首次前进后再移交画面。 final bool waitForPlaybackFrame; final String debugSlot; final int? debugIndex; final bool showFallbackBackground; final String videoUrl; final bool theater; final VoidCallback? onVideoVisible; /// iOS 画中画还原等场景的恢复信号:数值变化时回退到占位并重新等首帧, /// 避免纹理被系统回收后显示黑屏(封面占位 → 新帧淡入)。 final int resumeSignal; @override State createState() => _PlayerVideoSurfaceState(); } class _PlayerVideoSurfaceState extends State with SingleTickerProviderStateMixin { late final AnimationController _crossFade; VideoPlayerController? _attachedController; Widget? _placeholderSnapshot; Size? _videoSize; bool _playbackStarted = false; bool _frameConfirmed = false; bool _confirmationScheduled = false; bool _transitioning = false; bool _videoVisible = false; bool _videoVisibleNotified = false; /// 清晰度切换时新源 seek 后的进度;position 越过它才认定纹理已有画面。 Duration? _confirmBasePosition; /// 画中画还原等场景的首帧确认下限时刻:position 可能一拍就过基准, /// 但纹理出帧滞后于进度观测,淡入前强制等待一小段真实解码时间。 DateTime? _confirmNotBefore; String? _lastLayerState; @override void initState() { super.initState(); _crossFade = AnimationController(vsync: this, duration: _crossFadeDuration) ..addStatusListener(_handleFadeStatus); _placeholderSnapshot = widget.placeholder; _attachController(widget.controller); 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); } } @override void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.resumeSignal != widget.resumeSignal) { _logSlot('pip-resume-reset', widget.controller?.value); _resetForControllerChange(); _confirmNotBefore = DateTime.now().add(const Duration(milliseconds: 450)); setState(() {}); if (oldWidget.controller == widget.controller) return; } 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; _logSlot('controller-change-after', value); if (_firstFrameConfirmed(value)) { _confirmFirstFrame(initial: true); } else { _logLayer('placeholder', value); } return; } if (!_transitioning && !_videoVisible && oldWidget.placeholder != widget.placeholder) { _placeholderSnapshot = widget.placeholder; } if (oldWidget.thumbnailReady != widget.thumbnailReady || oldWidget.posterReady != widget.posterReady || oldWidget.fallback != widget.fallback || oldWidget.warmSlotReused != widget.warmSlotReused || oldWidget.warmFrameAvailable != widget.warmFrameAvailable) { final value = widget.controller?.value; _updatePlaybackFlags(value); if (!_frameConfirmed && _firstFrameConfirmed(value)) { _confirmFirstFrame(); } else { _logLayer('placeholder', value); } } } @override void dispose() { _logSlot('dispose', widget.controller?.value); _detachController(widget.controller); _crossFade ..removeStatusListener(_handleFadeStatus) ..dispose(); super.dispose(); } @override Widget build(BuildContext context) { final controller = widget.controller; final value = controller?.value; final videoSize = _videoSize; final showVideo = controller != null && videoSize != null; final showPlaceholder = !_videoVisible && _placeholderSnapshot != null; final video = showVideo ? _VideoLayer( controller: controller, size: videoSize, theater: widget.theater, ) : null; return Stack( fit: StackFit.expand, children: [ if (widget.showFallbackBackground) const ColoredBox( key: Key('player-fallback-background'), color: AppColors.mediaBlack, ), if (showPlaceholder) KeyedSubtree( key: const Key('player-single-placeholder'), child: _placeholderSnapshot!, ), if (_videoVisible && video != null) KeyedSubtree(key: const Key('player-video-layer'), child: video) else if (_transitioning && video != null) FadeTransition( key: const Key('player-video-crossfade'), opacity: _crossFade, child: video, ), if (kDebugMode && value?.hasError == true) Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: _errorHorizontal), child: Text( value?.errorDescription ?? 'unknown video error', key: const Key('player-video-error'), maxLines: _errorMaxLines, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted, fontSize: 13), ), ), ), ], ); } void _attachController(VideoPlayerController? controller) { _attachedController = controller; controller?.addListener(_handleControllerChanged); } void _detachController(VideoPlayerController? controller) { controller?.removeListener(_handleControllerChanged); if (_attachedController == controller) _attachedController = null; } void _handleControllerChanged() { final value = widget.controller?.value; final nextSize = value != null && value.isInitialized ? value.size : null; final sizeChanged = nextSize != _videoSize; _updatePlaybackFlags(value); _videoSize = nextSize; if (!_frameConfirmed && _firstFrameConfirmed(value)) { _scheduleFirstFrameConfirmation(); } else if (sizeChanged && mounted) { setState(() {}); } } void _updatePlaybackFlags(VideoPlayerValue? value) { _playbackStarted = _playbackStarted || value?.isPlaying == true; } void _scheduleFirstFrameConfirmation() { if (_confirmationScheduled || _frameConfirmed) return; _confirmationScheduled = true; if (kDebugMode) { debugPrint( '[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().millisecondsSinceEpoch} ' 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' 'phase=first-frame-callback mounted=$mounted', ); } _confirmationScheduled = false; if (!mounted || _frameConfirmed) return; if (_firstFrameConfirmed(widget.controller?.value)) { _confirmFirstFrame(); } }); WidgetsBinding.instance.scheduleFrame(); } bool _firstFrameConfirmed(VideoPlayerValue? value) { if (value == null || !value.isInitialized || value.hasError || value.size.width <= 0 || value.size.height <= 0) { return false; } if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true; // 清晰度切换:新源 seek 完成后 position 立即大于 0,但不代表纹理已有 // 画面;等 position 越过切换时刻的值(解码循环已在出帧)再确认,避免闪黑。 final base = _confirmBasePosition; if (base != null) { final notBefore = _confirmNotBefore; if (notBefore != null && DateTime.now().isBefore(notBefore)) { return false; } return value.position > base || value.isCompleted; } if (value.position > Duration.zero || value.isCompleted) return true; return _playbackStarted && !value.isPlaying && !value.isBuffering; } 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) { if (initial) { _videoVisible = true; _placeholderSnapshot = null; } else { setState(() { _videoVisible = true; _placeholderSnapshot = null; }); } _logLayer('video', widget.controller?.value); _notifyVideoVisible(); return; } if (initial) { _transitioning = true; _logLayer('fade-start', widget.controller?.value); _crossFade.forward(from: 0); } else { setState(() { _transitioning = true; _placeholderSnapshot = widget.placeholder; }); _logLayer('fade-start', widget.controller?.value); _crossFade.forward(from: 0); } } void _handleFadeStatus(AnimationStatus status) { if (status != AnimationStatus.completed || !mounted) return; setState(() { _transitioning = false; _videoVisible = true; _placeholderSnapshot = null; }); _logLayer('fade-end', widget.controller?.value); _notifyVideoVisible(); } void _resetForControllerChange() { _crossFade.reset(); _placeholderSnapshot = widget.placeholder; _videoSize = null; _playbackStarted = false; _frameConfirmed = false; _confirmationScheduled = false; _transitioning = false; _videoVisible = false; _videoVisibleNotified = false; _lastLayerState = null; // 记录新源切换时刻的进度,供首帧确认判断 position 是否已前进。 _confirmBasePosition = widget.controller?.value.position; } void _notifyVideoVisible() { if (_videoVisibleNotified) return; _videoVisibleNotified = true; final callback = widget.onVideoVisible; if (callback == null) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) callback(); }); } void _logLayer(String phase, VideoPlayerValue? value) { if (!kDebugMode) return; final fallback = phase == 'video' || phase == 'fade-end' ? '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} ' 'poster=${widget.posterReady ? 'ready' : 'none'} ' 'thumbnails=${widget.thumbnailReady ? 'ready' : 'pending'} ' 'fallback=$fallback ' 'init=${value?.isInitialized == true ? 1 : 0} ' 'position=${value?.position.inMilliseconds ?? 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' 'buffering=${value?.isBuffering == true ? 1 : 0} ' 'placeholder=${_placeholderLabel()}'; if (signature == _lastLayerState) return; _lastLayerState = signature; debugPrint( '[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 state=${identityHashCode(this)} ' 'controller=${widget.controller == null ? '-' : identityHashCode(widget.controller)} ' '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) { PlayerOpeningFallback.handoff => 'handoff', PlayerOpeningFallback.poster => 'poster', PlayerOpeningFallback.thumbnails => 'thumbnails-first-frame', PlayerOpeningFallback.black => 'black', }; } } /// 将旧 controller 作为唯一占位,不额外叠加 handoff 层。 class ControllerFramePlaceholder extends StatelessWidget { const ControllerFramePlaceholder({ super.key, required this.controller, required this.theater, }); final VideoPlayerController controller; final bool theater; @override Widget build(BuildContext context) { final size = controller.value.size; return FittedBox( fit: theater ? BoxFit.contain : BoxFit.cover, child: SizedBox( width: size.width, height: size.height, child: VideoPlayer(controller, key: ValueKey(controller)), ), ); } } class _VideoLayer extends StatelessWidget { const _VideoLayer({ required this.controller, required this.size, required this.theater, }); final VideoPlayerController controller; final Size size; final bool theater; @override Widget build(BuildContext context) => FittedBox( fit: theater ? BoxFit.contain : BoxFit.cover, child: SizedBox( width: size.width, height: size.height, child: VideoPlayer(controller, key: ValueKey(controller)), ), ); }