// 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'; 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.handoff, this.openingFrame, this.holdOpeningUntilFirstFrame = false, this.preparedFrameAvailable = false, }); final VideoPlayerController? controller; final VideoPlayerController? handoff; /// 解码出第一帧之前垫上的视频首帧,没有则为空。 final Widget? openingFrame; /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 final bool holdOpeningUntilFirstFrame; /// 相邻集预热槽已持有可用画面,进入页面时不需要封面兜底。 final bool preparedFrameAvailable; final String videoUrl; final bool theater; @override State createState() => _PlayerVideoSurfaceState(); } class _PlayerVideoSurfaceState extends State { String? _lastDebugState; String? _lastLayerState; VideoPlayerController? _firstFrameController; bool _playbackStarted = false; bool _firstFrameSeen = false; @override void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { _lastDebugState = null; _lastLayerState = null; _firstFrameController = null; _playbackStarted = false; _firstFrameSeen = false; } } @override Widget build(BuildContext context) { final controller = widget.controller; if (controller == null) return _frame(null); return ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) => _frame(value), ); } Widget _frame(VideoPlayerValue? value) { final controller = widget.controller; final handoff = _liveHandoff(controller); final firstFrameSeen = _trackFrameState(controller, value); final firstFrameReady = widget.preparedFrameAvailable || firstFrameSeen; final textureReady = _shouldReveal(value, handoff != null); final reveal = textureReady && (!widget.holdOpeningUntilFirstFrame || widget.preparedFrameAvailable || firstFrameReady || _playbackStarted && value?.isPlaying == false && value?.isBuffering != true); final showOpening = widget.openingFrame != null && handoff == null && !reveal; _logStateChange(value); _logLayerState( value, handoff, reveal, firstFrameReady, showOpening, ); return Stack( fit: StackFit.expand, children: [ const ColoredBox( key: Key('player-fallback-background'), color: AppColors.mediaBlack, ), if (showOpening) KeyedSubtree( key: const Key('player-opening-frame'), child: widget.openingFrame!, ), AnimatedOpacity( key: const Key('player-video-opacity'), opacity: reveal ? 1 : 0, duration: Duration.zero, child: reveal && controller != null ? _VideoLayer( controller: controller, value: value!, theater: widget.theater, ) : const SizedBox.expand(), ), if (handoff != null) _VideoLayer( key: const Key('player-video-handoff'), controller: handoff, value: handoff.value, theater: widget.theater, ), 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), ), ), ), ], ); } bool _shouldReveal(VideoPlayerValue? value, bool covered) { if (covered) return value?.isInitialized ?? false; if (value == null || value.hasError || !value.isInitialized) return false; return value.size.width > 0 && value.size.height > 0; } bool _trackFrameState( VideoPlayerController? controller, VideoPlayerValue? value, ) { if (!identical(_firstFrameController, controller)) { _firstFrameController = controller; _playbackStarted = false; _firstFrameSeen = false; } if (value == null) return false; _playbackStarted = _playbackStarted || value.isPlaying; _firstFrameSeen = _firstFrameSeen || value.position > Duration.zero || value.isCompleted; return _firstFrameSeen; } /// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。 VideoPlayerController? _liveHandoff(VideoPlayerController? controller) { final handoff = widget.handoff; if (handoff == null || identical(handoff, controller)) return null; return handoff; } void _logStateChange(VideoPlayerValue? value) { if (!kDebugMode) return; final status = _debugStatus(value); final signature = '$status|${value?.isInitialized ?? false}|' '${value?.errorDescription ?? 'none'}'; if (signature == _lastDebugState) return; _lastDebugState = signature; final source = widget.controller?.dataSource ?? widget.videoUrl; debugPrint( '[video] init=${value?.isInitialized ?? false} ' 'err=${value?.errorDescription ?? 'none'} host=${_host(source)}', ); } void _logLayerState( VideoPlayerValue? value, VideoPlayerController? handoff, bool reveal, bool firstFrameReady, bool showOpening, ) { if (!kDebugMode) return; final reason = handoff != null ? 'handoff' : !_shouldReveal(value, handoff != null) ? 'uninitialized' : widget.preparedFrameAvailable ? 'warm-frame' : !firstFrameReady && widget.holdOpeningUntilFirstFrame ? 'awaiting-first-frame' : 'reveal'; final signature = 'warm=${widget.preparedFrameAvailable ? 1 : 0} ' 'frame=${firstFrameReady ? 1 : 0} ' 'cover=${showOpening ? 1 : 0} ' 'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} ' 'init=${value?.isInitialized == true ? 1 : 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} ' 'buffering=${value?.isBuffering == true ? 1 : 0} ' 'reason=$reason'; if (signature == _lastLayerState) return; _lastLayerState = signature; debugPrint('[player-layer] $signature'); } String _debugStatus(VideoPlayerValue? value) { if (value == null) return 'loading'; if (value.hasError) { return 'error: ${value.errorDescription ?? 'unknown'}'; } if (value.isBuffering) return 'buffering'; if (value.isInitialized) return 'initialized'; return 'loading'; } String _host(String source) { final uri = Uri.tryParse(source); if (uri == null || uri.host.isEmpty) return 'unknown'; final port = uri.hasPort ? uri.port : uri.scheme == 'https' ? 443 : 80; return '${uri.host}:$port'; } } class _VideoLayer extends StatelessWidget { const _VideoLayer({ super.key, required this.controller, required this.value, required this.theater, }); final VideoPlayerController controller; final VideoPlayerValue value; final bool theater; @override Widget build(BuildContext context) { return FittedBox( fit: theater ? BoxFit.contain : BoxFit.cover, child: SizedBox( width: value.size.width, height: value.size.height, child: VideoPlayer(controller, key: ValueKey(controller)), ), ); } }