Files
ReelShort/lib/pages/player/widgets/player_video_surface.dart
T

469 lines
15 KiB
Dart

// 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<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
}
class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
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)),
),
);
}