petsHero-AI/lib/features/home/widgets/video_card.dart
2026-03-31 09:42:49 +08:00

474 lines
17 KiB
Dart
Raw 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' 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<int>? playbackResumeListenable;
final VoidCallback onPlayRequested;
final VoidCallback onStopRequested;
final VoidCallback? onGenerateSimilar;
@override
State<VideoCard> createState() => _VideoCardState();
}
class _VideoCardState extends State<VideoCard> {
VideoPlayerController? _controller;
/// 递增以作废过期的异步 [ _loadAndPlay ],避免多路初始化互相抢同一个 State
int _loadGen = 0;
/// 视频层是否已淡入(先 0 再置 true触发 [AnimatedOpacity]
bool _videoOpacityTarget = false;
/// 从网络拉取视频时显示底部细进度条(缓存命中则不会出现)
bool _showBottomProgress = false;
/// 01 为确定进度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<void> _runVideoFadeInSequence(int gen) async {
final c = _controller;
if (c == null || !mounted || gen != _loadGen || !widget.isActive) return;
// 与「视频层首帧 build」错开保证 AnimatedOpacity 从 0 开始做完整动画
await Future<void>.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<void>.delayed(const Duration(milliseconds: 24));
}
if (!mounted || gen != _loadGen || !widget.isActive) return;
// 再给 Texture 一瞬提交首帧,减轻与透明度不同步的突兀感
await Future<void>.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<void> _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 ??
(widget.imageUrl.isEmpty
? Container(color: AppColors.surfaceAlt)
: 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<Color>(
AppColors.primary,
),
),
),
],
),
),
);
},
);
}
}