diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index 435f52e..17666ab 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -1,3 +1,4 @@ +import 'dart:async' show unawaited; import 'dart:io' show File; import 'package:flutter/material.dart'; @@ -39,6 +40,8 @@ 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 等) @@ -71,7 +74,50 @@ class _VideoCardState extends State { void _stop() { _controller?.removeListener(_onVideoUpdate); _controller?.pause(); - if (mounted) setState(() {}); + 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() { @@ -113,7 +159,8 @@ class _VideoCardState extends State { _stop(); return; } - setState(() {}); + setState(() => _videoOpacityTarget = false); + _scheduleVideoFadeIn(gen); return; } @@ -175,12 +222,14 @@ class _VideoCardState extends State { _disposeController(); return; } - setState(() {}); + setState(() => _videoOpacityTarget = false); + _scheduleVideoFadeIn(gen); } catch (e) { if (mounted) { _disposeController(); setState(() { _clearBottomProgress(); + _videoOpacityTarget = false; }); widget.onStopRequested(); } @@ -193,7 +242,12 @@ class _VideoCardState extends State { @override Widget build(BuildContext context) { - final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized; + 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) { @@ -216,38 +270,45 @@ class _VideoCardState extends State { child: Stack( fit: StackFit.expand, children: [ - if (showVideo) - Positioned.fill( - 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!), - ), + Positioned.fill( + child: GestureDetector( + onTap: widget.onGenerateSimilar, + behavior: HitTestBehavior.opaque, + child: CachedNetworkImage( + imageUrl: widget.imageUrl, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: AppColors.surfaceAlt, + ), + errorWidget: (_, __, ___) => Container( + color: AppColors.surfaceAlt, ), ), - ) - else + ), + ), + if (showVideoLayer) Positioned.fill( - child: GestureDetector( - onTap: widget.onGenerateSimilar, - behavior: HitTestBehavior.opaque, - child: CachedNetworkImage( - imageUrl: widget.imageUrl, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: AppColors.surfaceAlt, - ), - errorWidget: (_, __, ___) => Container( - color: AppColors.surfaceAlt, + 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!), + ), + ), ), ), ),