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

184 lines
5.6 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 Duration _videoFadeDuration = Duration(milliseconds: 200);
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,
});
final VideoPlayerController? controller;
final VideoPlayerController? handoff;
/// 解码出第一帧之前垫上的视频首帧,没有则为空。
final Widget? openingFrame;
final String videoUrl;
final bool theater;
@override
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
}
class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
String? _lastDebugState;
@override
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) _lastDebugState = null;
}
@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 reveal = _shouldReveal(value, handoff != null);
_logStateChange(value);
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(
key: Key('player-fallback-background'),
color: AppColors.mediaBlack,
),
if (!reveal && widget.openingFrame != null)
KeyedSubtree(
key: const Key('player-opening-frame'),
child: widget.openingFrame!,
),
AnimatedOpacity(
key: const Key('player-video-opacity'),
opacity: reveal ? 1 : 0,
duration: handoff != null ? Duration.zero : _videoFadeDuration,
curve: Curves.easeOut,
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;
}
/// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。
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)}',
);
}
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)),
),
);
}
}