petsHero-AI/lib/features/home/widgets/video_card.dart

355 lines
11 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:io' show File;
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.credits = '50',
required this.isActive,
required this.onPlayRequested,
required this.onStopRequested,
this.onGenerateSimilar,
});
final String imageUrl;
final String? videoUrl;
final String credits;
final bool isActive;
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;
/// 从网络拉取视频时显示底部细进度条(缓存命中则不会出现)
bool _showBottomProgress = false;
/// 01 为确定进度null 为不确定(无 Content-Length 等)
double? _bottomProgressFraction;
@override
void initState() {
super.initState();
if (widget.isActive) {
WidgetsBinding.instance.addPostFrameCallback((_) => _loadAndPlay());
}
}
@override
void dispose() {
_disposeController();
super.dispose();
}
@override
void didUpdateWidget(covariant VideoCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive && !widget.isActive) {
_stop();
} else if (!oldWidget.isActive && widget.isActive) {
_loadAndPlay();
}
}
void _stop() {
_controller?.removeListener(_onVideoUpdate);
_controller?.pause();
if (mounted) setState(() {});
}
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) {
_stop();
return;
}
setState(() {});
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');
}
setState(_clearBottomProgress);
_controller = VideoPlayerController.file(
File(videoPath),
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
await _controller!.initialize();
if (!mounted || gen != _loadGen || !widget.isActive) {
_disposeController();
return;
}
_controller!.setVolume(0);
_controller!.setLooping(true);
_controller!.addListener(_onVideoUpdate);
await _controller!.play();
if (!mounted || gen != _loadGen || !widget.isActive) {
_disposeController();
return;
}
setState(() {});
} catch (e) {
if (mounted) {
_disposeController();
setState(() {
_clearBottomProgress();
});
widget.onStopRequested();
}
}
}
void _onVideoUpdate() {
// 循环播放,不需要停止
}
@override
Widget build(BuildContext context) {
final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized;
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: [
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!),
),
),
),
)
else
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,
),
),
),
),
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',
),
),
],
),
),
),
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,
),
),
),
],
),
),
);
},
);
}
}