fix(player): 重构单层过渡并阻止锁定集串播

This commit is contained in:
Zhou Weidong committed 2026-10-26 14:41:18 +08:00
1 parent 70d8e0f982
commit 0cb0ab0613
8 files changed
+657 -335

No files matched your search

+247 -165
View File
@@ -6,37 +6,36 @@ import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.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.handoff,
this.openingFrame,
this.openingFrameReady = false,
this.holdOpeningUntilFirstFrame = false,
this.preparedFrameAvailable = false,
this.placeholder,
this.warmFrameAvailable = false,
this.thumbnailReady = false,
this.waitForPlaybackFrame = false,
});
final VideoPlayerController? controller;
final VideoPlayerController? handoff;
/// 解码出第一帧之前垫上的视频首帧,没有则为空。
final Widget? openingFrame;
/// 视频首帧未确认时唯一的占位 widget。
final Widget? placeholder;
/// 当前集雪碧图 t=0 是否已在共享缓存中可用。
final bool openingFrameReady;
/// 相邻集预热槽已可用时直接出视频,不挂载占位。
final bool warmFrameAvailable;
/// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。
final bool holdOpeningUntilFirstFrame;
/// 当前集 t=0 雪碧图是否已命中缓存,仅用于调试判定链。
final bool thumbnailReady;
/// 相邻集预热槽已持有可用画面,进入页面时不需要封面兜底。
final bool preparedFrameAvailable;
/// 自动播放中的新集等待 position 首次前进后再移交画面。
final bool waitForPlaybackFrame;
final String videoUrl;
final bool theater;
@@ -44,53 +43,87 @@ class PlayerVideoSurface extends StatefulWidget {
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
}
class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
String? _lastDebugState;
String? _lastLayerState;
VideoPlayerController? _firstFrameController;
class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
with SingleTickerProviderStateMixin {
late final AnimationController _crossFade;
VideoPlayerController? _attachedController;
Widget? _placeholderSnapshot;
Size? _videoSize;
bool _playbackStarted = false;
bool _firstFrameSeen = false;
bool _frameConfirmed = false;
bool _confirmationScheduled = false;
bool _transitioning = false;
bool _videoVisible = false;
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;
if (_firstFrameConfirmed(value)) {
_confirmFirstFrame(initial: true);
}
}
@override
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
_lastDebugState = null;
_lastLayerState = null;
_firstFrameController = null;
_playbackStarted = false;
_firstFrameSeen = false;
_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);
return;
}
if (!_transitioning &&
!_videoVisible &&
oldWidget.placeholder != widget.placeholder) {
_placeholderSnapshot = widget.placeholder;
}
if (oldWidget.thumbnailReady != widget.thumbnailReady ||
oldWidget.warmFrameAvailable != widget.warmFrameAvailable) {
final value = widget.controller?.value;
_updatePlaybackFlags(value);
if (!_frameConfirmed && _firstFrameConfirmed(value)) {
_confirmFirstFrame();
} else {
_logLayer('placeholder', value);
}
}
}
@override
void dispose() {
_detachController(widget.controller);
_crossFade
..removeStatusListener(_handleFadeStatus)
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = widget.controller;
if (controller == null) return _frame(null);
return ValueListenableBuilder<VideoPlayerValue>(
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);
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: [
@@ -98,29 +131,18 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
key: Key('player-fallback-background'),
color: AppColors.mediaBlack,
),
if (showOpening)
if (showPlaceholder)
KeyedSubtree(
key: const Key('player-opening-frame'),
child: widget.openingFrame!,
key: const Key('player-single-placeholder'),
child: _placeholderSnapshot!,
),
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 (_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(
@@ -140,134 +162,194 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
);
}
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;
void _attachController(VideoPlayerController? controller) {
_attachedController = controller;
controller?.addListener(_handleControllerChanged);
}
bool _trackFrameState(
VideoPlayerController? controller,
VideoPlayerValue? value,
) {
if (!identical(_firstFrameController, controller)) {
_firstFrameController = controller;
_playbackStarted = false;
_firstFrameSeen = false;
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(() {});
}
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 _updatePlaybackFlags(VideoPlayerValue? value) {
_playbackStarted = _playbackStarted || value?.isPlaying == true;
}
void _logStateChange(VideoPlayerValue? value) {
void _scheduleFirstFrameConfirmation() {
if (_confirmationScheduled || _frameConfirmed) return;
_confirmationScheduled = true;
if (kDebugMode) {
debugPrint(
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} '
'phase=first-frame-scheduled',
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (kDebugMode) {
debugPrint(
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} '
'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;
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;
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);
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);
}
void _resetForControllerChange() {
_crossFade.reset();
_placeholderSnapshot = widget.placeholder;
_videoSize = null;
_playbackStarted = false;
_frameConfirmed = false;
_confirmationScheduled = false;
_transitioning = false;
_videoVisible = false;
_lastLayerState = null;
}
void _logLayer(String phase, 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 fallback = handoff != null
? 'handoff'
: showOpening
? widget.openingFrameReady
? 'thumbnails-first-frame'
: 'black'
: reveal
final fallback = phase == 'video' || phase == 'fade-end'
? 'video'
: _placeholderSnapshot == null
? 'black'
: widget.thumbnailReady
? 'thumbnails-first-frame'
: 'black';
final signature =
'warm=${widget.preparedFrameAvailable ? 1 : 0} '
'frame=${firstFrameReady ? 1 : 0} '
'thumbnails=${widget.openingFrameReady ? 'ready' : 'pending'} '
'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} '
'frame=${_frameConfirmed ? 1 : 0} '
'thumbnails=${widget.thumbnailReady ? 'ready' : 'pending'} '
'fallback=$fallback '
'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} '
'init=${value?.isInitialized == true ? 1 : 0} '
'position=${value?.position.inMilliseconds ?? 0} '
'playing=${value?.isPlaying == true ? 1 : 0} '
'buffering=${value?.isBuffering == true ? 1 : 0} '
'reason=$reason';
'buffering=${value?.isBuffering == true ? 1 : 0}';
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';
debugPrint(
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature',
);
}
}
class _VideoLayer extends StatelessWidget {
const _VideoLayer({
/// 将旧 controller 作为唯一占位,不额外叠加 handoff 层。
class ControllerFramePlaceholder extends StatelessWidget {
const ControllerFramePlaceholder({
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) {
final size = controller.value.size;
return FittedBox(
fit: theater ? BoxFit.contain : BoxFit.cover,
child: SizedBox(
width: value.size.width,
height: value.size.height,
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)),
),
);
}