440 lines
14 KiB
Dart
440 lines
14 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,
|
|
});
|
|
|
|
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;
|
|
|
|
@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;
|
|
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.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;
|
|
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;
|
|
}
|
|
|
|
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)),
|
|
),
|
|
);
|
|
}
|