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 createState() => _ResilientNetworkVideoCoverState(); } class _ResilientNetworkVideoCoverState extends State { 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 _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 _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), ), ), ), ), ), ], ); }, ); } }