266 lines
8.2 KiB
Dart
266 lines
8.2 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';
|
|
|
|
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<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
|
|
}
|
|
|
|
class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
|
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<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,
|
|
);
|
|
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)),
|
|
),
|
|
);
|
|
}
|
|
}
|