303 lines
8.0 KiB
Dart
303 lines
8.0 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:video_player/video_player.dart';
|
||
|
||
import '../core/video_file_cache.dart';
|
||
|
||
/// 网络视频播放:优先 **稳定 key** 的磁盘缓存(忽略签名 query),遇 ExoPlayer **416**、
|
||
/// [UnrecognizedInputFormatException](坏/HTML 缓存)、[VideoPlayerController] 报错时清缓存并重试
|
||
/// (与 [HomeScreen] `_HomeItemVideoBackground` 对齐)。
|
||
class ResilientNetworkVideoCover extends StatefulWidget {
|
||
const ResilientNetworkVideoCover({
|
||
super.key,
|
||
required this.url,
|
||
this.isActive = true,
|
||
this.looping = true,
|
||
this.volume = 1.0,
|
||
this.loadingWidget,
|
||
this.failedWidget,
|
||
});
|
||
|
||
final String url;
|
||
final bool isActive;
|
||
final bool looping;
|
||
final double volume;
|
||
final Widget? loadingWidget;
|
||
final Widget? failedWidget;
|
||
|
||
@override
|
||
State<ResilientNetworkVideoCover> createState() =>
|
||
_ResilientNetworkVideoCoverState();
|
||
}
|
||
|
||
class _ResilientNetworkVideoCoverState extends State<ResilientNetworkVideoCover> {
|
||
VideoPlayerController? _controller;
|
||
bool _failed = false;
|
||
|
||
static const int _maxOpenRetries = 6;
|
||
int _openRetries = 0;
|
||
bool _forceNetworkOnly = false;
|
||
Timer? _retryTimer;
|
||
bool _recovering = false;
|
||
|
||
String get _playUrl => widget.url.trim();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.isActive) {
|
||
unawaited(_startPlaybackAsync());
|
||
}
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(ResilientNetworkVideoCover oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.isActive != widget.isActive) {
|
||
if (!widget.isActive) {
|
||
_retryTimer?.cancel();
|
||
_retryTimer = null;
|
||
_recovering = false;
|
||
_openRetries = 0;
|
||
_disposePlayback();
|
||
if (mounted) setState(() {});
|
||
return;
|
||
}
|
||
_failed = false;
|
||
_forceNetworkOnly = false;
|
||
unawaited(_startPlaybackAsync());
|
||
return;
|
||
}
|
||
if (oldWidget.url != widget.url) {
|
||
_openRetries = 0;
|
||
_failed = false;
|
||
_recovering = false;
|
||
_forceNetworkOnly = false;
|
||
_disposePlayback();
|
||
if (widget.isActive) {
|
||
unawaited(_startPlaybackAsync());
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_retryTimer?.cancel();
|
||
_disposePlayback();
|
||
super.dispose();
|
||
}
|
||
|
||
static Future<void> _pauseThenDispose(VideoPlayerController c) async {
|
||
try {
|
||
await c.pause();
|
||
} catch (_) {}
|
||
try {
|
||
await c.dispose();
|
||
} catch (_) {}
|
||
}
|
||
|
||
void _disposePlayback() {
|
||
_retryTimer?.cancel();
|
||
_retryTimer = null;
|
||
final c = _controller;
|
||
if (c != null) {
|
||
c.removeListener(_onVideoValueChanged);
|
||
_controller = null;
|
||
unawaited(_pauseThenDispose(c));
|
||
}
|
||
}
|
||
|
||
Future<void> _startPlaybackAsync() async {
|
||
if (!widget.isActive) return;
|
||
final playUrl = _playUrl;
|
||
final uri = Uri.tryParse(playUrl);
|
||
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) {
|
||
if (mounted) setState(() => _failed = true);
|
||
return;
|
||
}
|
||
_disposePlayback();
|
||
|
||
final cacheKey = videoCacheKeyForUrl(playUrl);
|
||
VideoPlayerController? controller;
|
||
try {
|
||
if (!_forceNetworkOnly) {
|
||
final cached = await funymeeVideoCacheManager.getFileFromCache(cacheKey);
|
||
if (cached != null &&
|
||
cached.validTill.isAfter(DateTime.now()) &&
|
||
await cached.file.exists() &&
|
||
await videoCachedFileLooksPlayable(cached.file)) {
|
||
controller = VideoPlayerController.file(cached.file);
|
||
} else if (cached != null && await cached.file.exists()) {
|
||
await funymeeVideoCacheManager.removeFile(cacheKey);
|
||
}
|
||
}
|
||
controller ??= VideoPlayerController.networkUrl(uri);
|
||
} catch (_) {
|
||
controller = VideoPlayerController.networkUrl(uri);
|
||
}
|
||
|
||
if (!mounted || playUrl != _playUrl || !widget.isActive) {
|
||
unawaited(_pauseThenDispose(controller));
|
||
return;
|
||
}
|
||
|
||
controller
|
||
..setLooping(widget.looping)
|
||
..setVolume(widget.volume);
|
||
controller.addListener(_onVideoValueChanged);
|
||
_controller = controller;
|
||
|
||
try {
|
||
await controller.initialize().timeout(
|
||
const Duration(seconds: 20),
|
||
onTimeout: () =>
|
||
throw TimeoutException('video init', const Duration(seconds: 20)),
|
||
);
|
||
} catch (_) {
|
||
if (mounted) _scheduleRecoverFromError();
|
||
return;
|
||
}
|
||
|
||
if (!mounted ||
|
||
_controller != controller ||
|
||
playUrl != _playUrl ||
|
||
!widget.isActive) {
|
||
return;
|
||
}
|
||
if (controller.value.hasError) {
|
||
if (mounted) _scheduleRecoverFromError();
|
||
return;
|
||
}
|
||
|
||
_openRetries = 0;
|
||
try {
|
||
await controller.play();
|
||
} catch (_) {
|
||
if (mounted) _scheduleRecoverFromError();
|
||
return;
|
||
}
|
||
|
||
if (!mounted ||
|
||
_controller != controller ||
|
||
playUrl != _playUrl ||
|
||
!widget.isActive) {
|
||
return;
|
||
}
|
||
if (controller.value.hasError) {
|
||
if (mounted) _scheduleRecoverFromError();
|
||
return;
|
||
}
|
||
|
||
_forceNetworkOnly = false;
|
||
unawaited(funymeeVideoCacheManager.downloadFile(playUrl, key: cacheKey));
|
||
setState(() {});
|
||
}
|
||
|
||
void _onVideoValueChanged() {
|
||
final c = _controller;
|
||
if (c == null || !mounted || _recovering) return;
|
||
if (!c.value.hasError) return;
|
||
c.removeListener(_onVideoValueChanged);
|
||
_scheduleRecoverFromError();
|
||
}
|
||
|
||
void _scheduleRecoverFromError() {
|
||
if (!mounted || _failed || !widget.isActive) return;
|
||
if (_recovering) return;
|
||
if (_openRetries >= _maxOpenRetries) {
|
||
setState(() => _failed = true);
|
||
_disposePlayback();
|
||
return;
|
||
}
|
||
_recovering = true;
|
||
_openRetries += 1;
|
||
final cacheKey = videoCacheKeyForUrl(_playUrl);
|
||
unawaited(funymeeVideoCacheManager.removeFile(cacheKey));
|
||
_forceNetworkOnly = true;
|
||
_disposePlayback();
|
||
setState(() {});
|
||
|
||
_retryTimer?.cancel();
|
||
_retryTimer = Timer(const Duration(milliseconds: 220), () {
|
||
_retryTimer = null;
|
||
if (!mounted) return;
|
||
_recovering = false;
|
||
unawaited(_startPlaybackAsync());
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (!widget.isActive) {
|
||
return SizedBox.expand(
|
||
child: widget.loadingWidget ?? const SizedBox.shrink(),
|
||
);
|
||
}
|
||
if (_failed) {
|
||
return SizedBox.expand(
|
||
child: widget.failedWidget ?? const SizedBox.shrink(),
|
||
);
|
||
}
|
||
final c = _controller;
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
if (c == null || !c.value.isInitialized) {
|
||
return SizedBox.expand(
|
||
child: widget.loadingWidget ??
|
||
const ColoredBox(
|
||
color: Colors.black,
|
||
child: Center(
|
||
child: CircularProgressIndicator(color: Colors.white54),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
final cw = constraints.maxWidth;
|
||
final ch = constraints.maxHeight;
|
||
final w = c.value.size.width;
|
||
final h = c.value.size.height;
|
||
if (w <= 0 ||
|
||
h <= 0 ||
|
||
!cw.isFinite ||
|
||
!ch.isFinite ||
|
||
cw <= 0 ||
|
||
ch <= 0) {
|
||
return SizedBox.expand(
|
||
child: widget.loadingWidget ??
|
||
const ColoredBox(
|
||
color: Colors.black,
|
||
child: Center(
|
||
child: CircularProgressIndicator(color: Colors.white54),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
Positioned.fill(
|
||
child: ClipRect(
|
||
child: SizedBox(
|
||
width: cw,
|
||
height: ch,
|
||
child: FittedBox(
|
||
fit: BoxFit.cover,
|
||
alignment: Alignment.center,
|
||
clipBehavior: Clip.none,
|
||
child: SizedBox(
|
||
width: w,
|
||
height: h,
|
||
child: VideoPlayer(c),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|