import 'dart:async' show unawaited; import 'dart:io' show File; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart' show DefaultCacheManager, DownloadProgress, FileInfo; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; /// Video card for home grid:在 [isActive] 为 true 时自动静音循环播放 [videoUrl]; /// 父组件按视口控制多个卡片可同时处于播放状态。 class VideoCard extends StatefulWidget { const VideoCard({ super.key, required this.imageUrl, this.videoUrl, this.cover, this.credits = '50', this.showCreditsBadge = true, this.showBottomGenerateButton = true, required this.isActive, /// 通常绑定首页全局 nonce;递增时若 [isActive] 则恢复解码/播放(前后台、路由返回等与可见性刷新统一) this.playbackResumeListenable, required this.onPlayRequested, required this.onStopRequested, this.onGenerateSimilar, }); final String imageUrl; final String? videoUrl; /// 非空时用自定义封面(如相册视频缩略图),替代 [CachedNetworkImage](imageUrl) final Widget? cover; final String credits; final bool showCreditsBadge; final bool showBottomGenerateButton; final bool isActive; final ValueListenable? playbackResumeListenable; final VoidCallback onPlayRequested; final VoidCallback onStopRequested; final VoidCallback? onGenerateSimilar; @override State createState() => _VideoCardState(); } class _VideoCardState extends State { VideoPlayerController? _controller; /// 递增以作废过期的异步 [ _loadAndPlay ],避免多路初始化互相抢同一个 State int _loadGen = 0; /// 视频层是否已淡入(先 0 再置 true,触发 [AnimatedOpacity]) bool _videoOpacityTarget = false; /// 从网络拉取视频时显示底部细进度条(缓存命中则不会出现) bool _showBottomProgress = false; /// 0–1 为确定进度;null 为不确定(无 Content-Length 等) double? _bottomProgressFraction; @override void initState() { super.initState(); widget.playbackResumeListenable?.addListener(_onPlaybackResumeSignal); if (widget.isActive) { WidgetsBinding.instance.addPostFrameCallback((_) => _loadAndPlay()); } } @override void dispose() { widget.playbackResumeListenable?.removeListener(_onPlaybackResumeSignal); _disposeController(); super.dispose(); } void _onPlaybackResumeSignal() { if (!mounted || !widget.isActive) return; // [homePlaybackResumeNonce] 可能在其他路由 dispose 等「树仍锁定」时同步递增; // 监听器里若立刻 [_kickPlaybackAfterGlobalResume] → setState 会抛错。延后到当前同步栈结束。 Future.microtask(() { if (!mounted || !widget.isActive) return; _kickPlaybackAfterGlobalResume(); }); } /// 与 Visibility 刷新并列:系统从后台恢复时往往已 pause 解码器,需显式 [play],不能仅依赖 [didUpdateWidget]。 void _kickPlaybackAfterGlobalResume() { if (widget.videoUrl == null || widget.videoUrl!.isEmpty) return; final c = _controller; if (c != null && c.value.isInitialized) { c.setVolume(0); c.setLooping(true); if (!c.value.isPlaying) { unawaited(c.play()); } return; } _loadAndPlay(); } @override void didUpdateWidget(covariant VideoCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.playbackResumeListenable != widget.playbackResumeListenable) { oldWidget.playbackResumeListenable?.removeListener(_onPlaybackResumeSignal); widget.playbackResumeListenable?.addListener(_onPlaybackResumeSignal); } if (oldWidget.isActive && !widget.isActive) { _releaseVideoDecoder(); } else if (!oldWidget.isActive && widget.isActive) { _loadAndPlay(); } } /// 仅 pause 会占用 MediaCodec;滑出网格后必须 [dispose] ExoPlayer,否则多划几次后 OMX 解码器耗尽报错。 void _releaseVideoDecoder() { _loadGen++; _controller?.removeListener(_onVideoUpdate); _disposeController(); _clearBottomProgress(); if (mounted) { setState(() { _videoOpacityTarget = false; }); } } /// 淡入略长 + easeInOut,避免「闪一下」的感觉 static const Duration _videoFadeDuration = Duration(milliseconds: 820); /// 在 [play] 之后最多等到首帧再开始淡入,否则解码器突然出画会盖过 opacity 动画 static const Duration _videoFadeLeadInDeadline = Duration(milliseconds: 1200); void _scheduleVideoFadeIn(int gen) { unawaited(_runVideoFadeInSequence(gen)); } Future _runVideoFadeInSequence(int gen) async { final c = _controller; if (c == null || !mounted || gen != _loadGen || !widget.isActive) return; // 与「视频层首帧 build」错开,保证 AnimatedOpacity 从 0 开始做完整动画 await Future.delayed(const Duration(milliseconds: 16)); if (!mounted || gen != _loadGen || !widget.isActive) return; final leadInEnd = DateTime.now().add(_videoFadeLeadInDeadline); while (mounted && gen == _loadGen && widget.isActive && DateTime.now().isBefore(leadInEnd)) { final v = c.value; if (v.isInitialized && v.isPlaying) { final likelyHasFrame = v.position > const Duration(milliseconds: 56) || (!v.isBuffering && v.size.width > 1 && v.size.height > 1); if (likelyHasFrame) break; } await Future.delayed(const Duration(milliseconds: 24)); } if (!mounted || gen != _loadGen || !widget.isActive) return; // 再给 Texture 一瞬提交首帧,减轻与透明度不同步的突兀感 await Future.delayed(const Duration(milliseconds: 52)); if (!mounted || gen != _loadGen || !widget.isActive) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || gen != _loadGen || !widget.isActive) return; setState(() => _videoOpacityTarget = true); }); } void _disposeController() { _controller?.removeListener(_onVideoUpdate); _controller?.dispose(); _controller = null; } void _clearBottomProgress() { if (!_showBottomProgress && _bottomProgressFraction == null) return; _showBottomProgress = false; _bottomProgressFraction = null; } Future _loadAndPlay() async { if (widget.videoUrl == null || widget.videoUrl!.isEmpty) { widget.onStopRequested(); return; } final gen = ++_loadGen; if (_controller != null && _controller!.value.isInitialized) { if (!widget.isActive) return; final needSeek = _controller!.value.position >= _controller!.value.duration && _controller!.value.duration.inMilliseconds > 0; if (needSeek) { await _controller!.seekTo(Duration.zero); } if (!mounted || gen != _loadGen || !widget.isActive) return; _controller!.setVolume(0); _controller!.setLooping(true); _controller!.removeListener(_onVideoUpdate); _controller!.addListener(_onVideoUpdate); await _controller!.play(); if (!mounted || gen != _loadGen) return; if (!widget.isActive) { _releaseVideoDecoder(); return; } setState(() => _videoOpacityTarget = false); _scheduleVideoFadeIn(gen); return; } if (_controller != null) { _disposeController(); } if (!widget.isActive) return; if (mounted) { setState(() { _clearBottomProgress(); }); } try { String? videoPath; await for (final response in DefaultCacheManager().getFileStream( widget.videoUrl!, withProgress: true, )) { if (!mounted || gen != _loadGen || !widget.isActive) { if (mounted) { setState(_clearBottomProgress); } return; } if (response is DownloadProgress) { setState(() { _showBottomProgress = true; _bottomProgressFraction = response.progress; }); } else if (response is FileInfo) { videoPath = response.file.path; } } if (!mounted || gen != _loadGen || !widget.isActive) { if (mounted) setState(_clearBottomProgress); return; } if (videoPath == null || videoPath.isEmpty) { throw StateError('Video file stream ended without FileInfo'); } // 下载结束后到解码完成前:底部 2px 不确定进度(缓存直出时下载阶段可能无 DownloadProgress) if (mounted) { setState(() { _showBottomProgress = true; _bottomProgressFraction = null; }); } _controller = VideoPlayerController.file( File(videoPath), videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), ); await _controller!.initialize(); if (!mounted || gen != _loadGen || !widget.isActive) { if (mounted) setState(_clearBottomProgress); _disposeController(); return; } if (mounted) setState(_clearBottomProgress); _controller!.setVolume(0); _controller!.setLooping(true); _controller!.addListener(_onVideoUpdate); await _controller!.play(); if (!mounted || gen != _loadGen || !widget.isActive) { _disposeController(); return; } setState(() => _videoOpacityTarget = false); _scheduleVideoFadeIn(gen); } catch (e) { if (mounted) { _disposeController(); setState(() { _clearBottomProgress(); _videoOpacityTarget = false; }); widget.onStopRequested(); } } } void _onVideoUpdate() { // 循环播放,不需要停止 } @override Widget build(BuildContext context) { final hasVideoUrl = widget.videoUrl != null && widget.videoUrl!.isNotEmpty; final videoInitialized = _controller != null && _controller!.value.isInitialized; final showVideoLayer = widget.isActive && hasVideoUrl && videoInitialized; return LayoutBuilder( builder: (context, constraints) { return Container( width: constraints.maxWidth, height: constraints.maxHeight, decoration: BoxDecoration( color: Colors.transparent, borderRadius: BorderRadius.circular(24), boxShadow: const [ BoxShadow( color: AppColors.shadowMedium, blurRadius: 12, offset: Offset(0, 4), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(24), child: Stack( fit: StackFit.expand, children: [ Positioned.fill( child: GestureDetector( onTap: widget.onGenerateSimilar, behavior: HitTestBehavior.opaque, child: widget.cover ?? CachedNetworkImage( imageUrl: widget.imageUrl, fit: BoxFit.cover, placeholder: (_, __) => Container( color: AppColors.surfaceAlt, ), errorWidget: (_, __, ___) => Container( color: AppColors.surfaceAlt, ), ), ), ), if (showVideoLayer) Positioned.fill( child: IgnorePointer( ignoring: !_videoOpacityTarget, child: AnimatedOpacity( opacity: _videoOpacityTarget ? 1.0 : 0.0, duration: _videoFadeDuration, curve: Curves.easeInOutCubic, child: GestureDetector( onTap: widget.onGenerateSimilar, behavior: HitTestBehavior.opaque, child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: _controller!.value.size.width > 0 ? _controller!.value.size.width : 16, height: _controller!.value.size.height > 0 ? _controller!.value.size.height : 9, child: VideoPlayer(_controller!), ), ), ), ), ), ), if (widget.showCreditsBadge) Positioned( top: 12, right: 12, child: Container( height: 24, padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: AppColors.overlayDark, borderRadius: BorderRadius.circular(14), ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( LucideIcons.sparkles, size: 12, color: AppColors.surface, ), const SizedBox(width: 4), Text( widget.credits, style: const TextStyle( color: AppColors.surface, fontSize: 11, fontWeight: FontWeight.w600, fontFamily: 'Inter', ), ), ], ), ), ), if (widget.showBottomGenerateButton) Positioned( bottom: 16, left: 12, right: 12, child: Center( child: IntrinsicWidth( child: GestureDetector( onTap: widget.onGenerateSimilar, child: Container( height: 24, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: AppColors.primaryButtonFill, borderRadius: BorderRadius.circular(12), border: Border.all( color: AppColors.primary, width: 1, ), boxShadow: const [ BoxShadow( color: AppColors.primaryButtonShadow, blurRadius: 6, offset: Offset(0, 2), ), ], ), alignment: Alignment.center, child: const Text( 'Generate Similar', style: TextStyle( color: AppColors.surface, fontSize: 12, fontWeight: FontWeight.w600, fontFamily: 'Inter', ), ), ), ), ), ), ), if (_showBottomProgress) Positioned( left: 0, right: 0, bottom: 0, height: 2, child: LinearProgressIndicator( value: _bottomProgressFraction, minHeight: 2, backgroundColor: AppColors.surfaceAlt, valueColor: const AlwaysStoppedAnimation( AppColors.primary, ), ), ), ], ), ), ); }, ); } }