FunyMeeAI/lib/widgets/resilient_network_video.dart
2026-04-13 22:25:08 +08:00

303 lines
8.0 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
),
),
),
),
],
);
},
);
}
}