|
|
@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; |
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
|
import 'package:im_flutter_sdk/im_flutter_sdk.dart'; |
|
|
import 'package:im_flutter_sdk/im_flutter_sdk.dart'; |
|
|
import 'package:get/get.dart'; |
|
|
import 'package:get/get.dart'; |
|
|
|
|
|
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; |
|
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart'; |
|
|
import 'package:video_player/video_player.dart'; |
|
|
import 'package:video_player/video_player.dart'; |
|
|
import 'package:dating_touchme_app/pages/message/video_player_page.dart'; |
|
|
import 'package:dating_touchme_app/pages/message/video_player_page.dart'; |
|
|
|
|
|
|
|
|
@ -24,73 +26,96 @@ class VideoItem extends StatefulWidget { |
|
|
State<VideoItem> createState() => _VideoItemState(); |
|
|
State<VideoItem> createState() => _VideoItemState(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 消息发送状态枚举 |
|
|
|
|
|
enum MessageSendStatus { |
|
|
|
|
|
sending, |
|
|
|
|
|
success, |
|
|
|
|
|
failed, |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
class _VideoItemState extends State<VideoItem> { |
|
|
class _VideoItemState extends State<VideoItem> { |
|
|
VideoPlayerController? _controller; |
|
|
|
|
|
bool _isInitialized = false; |
|
|
|
|
|
String? _thumbnailPath; |
|
|
String? _thumbnailPath; |
|
|
|
|
|
bool _isLoadingVideo = false; |
|
|
|
|
|
|
|
|
@override |
|
|
@override |
|
|
void initState() { |
|
|
void initState() { |
|
|
super.initState(); |
|
|
super.initState(); |
|
|
_initializeVideo(); |
|
|
|
|
|
|
|
|
// 🚀 极致性能优化:只准备缩略图路径,完全不初始化视频控制器 |
|
|
|
|
|
_prepareThumbnail(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
Future<void> _initializeVideo() async { |
|
|
|
|
|
|
|
|
/// 准备缩略图路径(轻量级操作) |
|
|
|
|
|
void _prepareThumbnail() { |
|
|
|
|
|
final thumbLocal = widget.videoBody.thumbnailLocalPath; |
|
|
|
|
|
final thumbRemote = widget.videoBody.thumbnailRemotePath; |
|
|
|
|
|
|
|
|
|
|
|
print('🖼️ [VideoItem] 缩略图调试信息:'); |
|
|
|
|
|
print('本地缩略图路径: $thumbLocal'); |
|
|
|
|
|
print('远程缩略图路径: $thumbRemote'); |
|
|
|
|
|
|
|
|
|
|
|
// 优先使用本地缩略图 |
|
|
|
|
|
if (thumbLocal != null && thumbLocal.isNotEmpty) { |
|
|
|
|
|
final file = File(thumbLocal); |
|
|
|
|
|
if (file.existsSync()) { |
|
|
|
|
|
_thumbnailPath = thumbLocal; |
|
|
|
|
|
print('✅ 使用本地缩略图: $thumbLocal'); |
|
|
|
|
|
return; |
|
|
|
|
|
} else { |
|
|
|
|
|
print('⚠️ 本地缩略图文件不存在: $thumbLocal'); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 使用远程缩略图 |
|
|
|
|
|
if (thumbRemote != null && thumbRemote.isNotEmpty) { |
|
|
|
|
|
_thumbnailPath = thumbRemote; |
|
|
|
|
|
print('✅ 使用远程缩略图: $thumbRemote'); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 🎯 备选方案:如果没有缩略图,尝试使用视频第一帧 |
|
|
|
|
|
final videoLocal = widget.videoBody.localPath; |
|
|
|
|
|
final videoRemote = widget.videoBody.remotePath; |
|
|
|
|
|
|
|
|
|
|
|
print('⚠️ 没有缩略图,尝试使用视频路径作为预览'); |
|
|
|
|
|
print('本地视频路径: $videoLocal'); |
|
|
|
|
|
print('远程视频路径: $videoRemote'); |
|
|
|
|
|
|
|
|
|
|
|
// 如果有本地视频,使用视频文件生成预览 |
|
|
|
|
|
if (videoLocal.isNotEmpty && File(videoLocal).existsSync()) { |
|
|
|
|
|
print('💡 [VideoItem] 将使用视频第一帧作为预览'); |
|
|
|
|
|
_generateThumbnailFromVideo(videoLocal); |
|
|
|
|
|
} else { |
|
|
|
|
|
print('❌ 没有可用的缩略图和视频,将显示占位符图标'); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/// 🎯 从视频生成缩略图(备选方案) |
|
|
|
|
|
void _generateThumbnailFromVideo(String videoPath) async { |
|
|
try { |
|
|
try { |
|
|
// 获取本地视频路径 |
|
|
|
|
|
final localPath = widget.videoBody.localPath; |
|
|
|
|
|
final remotePath = widget.videoBody.remotePath; |
|
|
|
|
|
|
|
|
print('🎬 [VideoItem] 开始从视频生成缩略图...'); |
|
|
|
|
|
|
|
|
print('=== 视频消息调试信息 ==='); |
|
|
|
|
|
print('本地路径: $localPath'); |
|
|
|
|
|
print('远程路径: $remotePath'); |
|
|
|
|
|
print('视频时长: ${widget.videoBody.duration}秒'); |
|
|
|
|
|
|
|
|
// 使用 video_player 获取第一帧 |
|
|
|
|
|
final controller = VideoPlayerController.file(File(videoPath)); |
|
|
|
|
|
await controller.initialize(); |
|
|
|
|
|
|
|
|
if (localPath.isNotEmpty && File(localPath).existsSync()) { |
|
|
|
|
|
// 使用本地文件 |
|
|
|
|
|
print('使用本地视频文件'); |
|
|
|
|
|
_controller = VideoPlayerController.file(File(localPath)); |
|
|
|
|
|
} else if (remotePath != null && remotePath.isNotEmpty) { |
|
|
|
|
|
// 使用远程URL |
|
|
|
|
|
print('使用远程视频URL'); |
|
|
|
|
|
_controller = VideoPlayerController.networkUrl(Uri.parse(remotePath)); |
|
|
|
|
|
} else { |
|
|
|
|
|
print('⚠️ 警告: 没有可用的视频路径'); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (_controller != null) { |
|
|
|
|
|
await _controller!.initialize(); |
|
|
|
|
|
|
|
|
// 设置缩略图路径为视频路径(让 UI 知道要使用视频预览) |
|
|
|
|
|
if (mounted) { |
|
|
setState(() { |
|
|
setState(() { |
|
|
_isInitialized = true; |
|
|
|
|
|
|
|
|
_thumbnailPath = 'video:$videoPath'; // 特殊标记 |
|
|
}); |
|
|
}); |
|
|
print('✅ 视频初始化成功'); |
|
|
|
|
|
|
|
|
print('✅ [VideoItem] 使用视频第一帧作为预览'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 获取缩略图路径 |
|
|
|
|
|
final thumbLocal = widget.videoBody.thumbnailLocalPath; |
|
|
|
|
|
final thumbRemote = widget.videoBody.thumbnailRemotePath; |
|
|
|
|
|
|
|
|
|
|
|
print('缩略图本地路径: $thumbLocal'); |
|
|
|
|
|
print('缩略图远程路径: $thumbRemote'); |
|
|
|
|
|
|
|
|
|
|
|
if (thumbLocal != null && thumbLocal.isNotEmpty) { |
|
|
|
|
|
_thumbnailPath = thumbLocal; |
|
|
|
|
|
print('使用本地缩略图'); |
|
|
|
|
|
} else if (thumbRemote != null && thumbRemote.isNotEmpty) { |
|
|
|
|
|
_thumbnailPath = thumbRemote; |
|
|
|
|
|
print('使用远程缩略图'); |
|
|
|
|
|
} else { |
|
|
|
|
|
print('⚠️ 警告: 没有可用的缩略图'); |
|
|
|
|
|
} |
|
|
|
|
|
print('======================'); |
|
|
|
|
|
|
|
|
// 释放控制器 |
|
|
|
|
|
controller.dispose(); |
|
|
} catch (e) { |
|
|
} catch (e) { |
|
|
print('❌ 初始化视频失败: $e'); |
|
|
|
|
|
|
|
|
print('❌ [VideoItem] 生成视频预览失败: $e'); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@override |
|
|
@override |
|
|
void dispose() { |
|
|
void dispose() { |
|
|
_controller?.dispose(); |
|
|
|
|
|
|
|
|
// 无需释放资源,因为没有初始化任何控制器 |
|
|
super.dispose(); |
|
|
super.dispose(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -117,7 +142,12 @@ class _VideoItemState extends State<VideoItem> { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 播放视频 |
|
|
// 播放视频 |
|
|
void _playVideo() { |
|
|
|
|
|
|
|
|
void _playVideo() async { |
|
|
|
|
|
// 如果正在加载,不处理点击 |
|
|
|
|
|
if (_isLoadingVideo) { |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
// 获取视频路径 |
|
|
// 获取视频路径 |
|
|
final localPath = widget.videoBody.localPath; |
|
|
final localPath = widget.videoBody.localPath; |
|
|
final remotePath = widget.videoBody.remotePath; |
|
|
final remotePath = widget.videoBody.remotePath; |
|
|
@ -135,8 +165,18 @@ class _VideoItemState extends State<VideoItem> { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (videoPath != null) { |
|
|
if (videoPath != null) { |
|
|
|
|
|
// 显示加载状态(如果是网络视频) |
|
|
|
|
|
if (isNetwork) { |
|
|
|
|
|
setState(() { |
|
|
|
|
|
_isLoadingVideo = true; |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// 模拟加载延迟(可选) |
|
|
|
|
|
await Future.delayed(Duration(milliseconds: 300)); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
// 使用 Chewie 播放器页面 |
|
|
// 使用 Chewie 播放器页面 |
|
|
Get.to( |
|
|
|
|
|
|
|
|
await Get.to( |
|
|
() => VideoPlayerPage( |
|
|
() => VideoPlayerPage( |
|
|
videoPath: videoPath!, |
|
|
videoPath: videoPath!, |
|
|
isNetwork: isNetwork, |
|
|
isNetwork: isNetwork, |
|
|
@ -144,12 +184,15 @@ class _VideoItemState extends State<VideoItem> { |
|
|
transition: Transition.fade, |
|
|
transition: Transition.fade, |
|
|
duration: const Duration(milliseconds: 200), |
|
|
duration: const Duration(milliseconds: 200), |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
// 隐藏加载状态 |
|
|
|
|
|
if (mounted && isNetwork) { |
|
|
|
|
|
setState(() { |
|
|
|
|
|
_isLoadingVideo = false; |
|
|
|
|
|
}); |
|
|
|
|
|
} |
|
|
} else { |
|
|
} else { |
|
|
Get.snackbar( |
|
|
|
|
|
'提示', |
|
|
|
|
|
'视频路径不可用', |
|
|
|
|
|
snackPosition: SnackPosition.BOTTOM, |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
SmartDialog.showToast('⚠️ 视频路径不可用,请稍后重试'); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -166,88 +209,142 @@ class _VideoItemState extends State<VideoItem> { |
|
|
: MainAxisAlignment.start, |
|
|
: MainAxisAlignment.start, |
|
|
crossAxisAlignment: CrossAxisAlignment.start, |
|
|
crossAxisAlignment: CrossAxisAlignment.start, |
|
|
children: [ |
|
|
children: [ |
|
|
// 视频消息气泡 |
|
|
|
|
|
GestureDetector( |
|
|
|
|
|
onTap: _playVideo, |
|
|
|
|
|
child: Container( |
|
|
|
|
|
width: 200.w, |
|
|
|
|
|
height: 150.h, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.grey[300], |
|
|
|
|
|
borderRadius: BorderRadius.circular(8.w), |
|
|
|
|
|
), |
|
|
|
|
|
child: ClipRRect( |
|
|
|
|
|
borderRadius: BorderRadius.circular(8.w), |
|
|
|
|
|
child: Stack( |
|
|
|
|
|
fit: StackFit.expand, |
|
|
|
|
|
children: [ |
|
|
|
|
|
// 背景层:始终显示占位符 |
|
|
|
|
|
Container( |
|
|
|
|
|
color: Colors.grey[300], |
|
|
|
|
|
child: Icon( |
|
|
|
|
|
Icons.videocam, |
|
|
|
|
|
size: 48.w, |
|
|
|
|
|
color: Colors.grey[600], |
|
|
|
|
|
|
|
|
// 🚀 极致性能优化:无需初始化,直接显示缩略图 |
|
|
|
|
|
Stack( |
|
|
|
|
|
children: [ |
|
|
|
|
|
GestureDetector( |
|
|
|
|
|
onTap: _playVideo, |
|
|
|
|
|
child: Container( |
|
|
|
|
|
width: 200.w, |
|
|
|
|
|
height: 150.h, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.grey[300], |
|
|
|
|
|
borderRadius: BorderRadius.circular(12.w), |
|
|
|
|
|
boxShadow: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.1), |
|
|
|
|
|
blurRadius: 8, |
|
|
|
|
|
offset: Offset(0, 2), |
|
|
), |
|
|
), |
|
|
), |
|
|
|
|
|
// 视频层或缩略图层 |
|
|
|
|
|
if (_isInitialized && _controller != null) |
|
|
|
|
|
Center( |
|
|
|
|
|
child: AspectRatio( |
|
|
|
|
|
aspectRatio: _controller!.value.aspectRatio, |
|
|
|
|
|
child: VideoPlayer(_controller!), |
|
|
|
|
|
), |
|
|
|
|
|
) |
|
|
|
|
|
else if (_thumbnailPath != null && _thumbnailPath!.isNotEmpty) |
|
|
|
|
|
_buildThumbnail(), |
|
|
|
|
|
// 播放按钮和时长覆盖层 |
|
|
|
|
|
Container( |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
gradient: LinearGradient( |
|
|
|
|
|
begin: Alignment.topCenter, |
|
|
|
|
|
end: Alignment.bottomCenter, |
|
|
|
|
|
colors: [ |
|
|
|
|
|
Colors.transparent, |
|
|
|
|
|
Colors.black.withOpacity(0.4), |
|
|
|
|
|
], |
|
|
|
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
child: ClipRRect( |
|
|
|
|
|
borderRadius: BorderRadius.circular(12.w), |
|
|
|
|
|
child: Stack( |
|
|
|
|
|
fit: StackFit.expand, |
|
|
|
|
|
children: [ |
|
|
|
|
|
// 🚀 背景层:默认占位符 |
|
|
|
|
|
Container( |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
gradient: LinearGradient( |
|
|
|
|
|
begin: Alignment.topLeft, |
|
|
|
|
|
end: Alignment.bottomRight, |
|
|
|
|
|
colors: [ |
|
|
|
|
|
Colors.grey[300]!, |
|
|
|
|
|
Colors.grey[400]!, |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
child: Icon( |
|
|
|
|
|
Icons.videocam, |
|
|
|
|
|
size: 48.w, |
|
|
|
|
|
color: Colors.grey[600], |
|
|
|
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
|
|
|
child: Column( |
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center, |
|
|
|
|
|
children: [ |
|
|
|
|
|
// 播放按钮 |
|
|
|
|
|
Icon( |
|
|
|
|
|
Icons.play_circle_filled, |
|
|
|
|
|
size: 56.w, |
|
|
|
|
|
color: Colors.white.withOpacity(0.9), |
|
|
|
|
|
|
|
|
// 🚀 缩略图层:始终显示缩略图(如果有) |
|
|
|
|
|
if (_thumbnailPath != null && _thumbnailPath!.isNotEmpty) |
|
|
|
|
|
_buildThumbnail(), |
|
|
|
|
|
// 播放按钮和时长覆盖层 |
|
|
|
|
|
Container( |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
gradient: LinearGradient( |
|
|
|
|
|
begin: Alignment.topCenter, |
|
|
|
|
|
end: Alignment.bottomCenter, |
|
|
|
|
|
colors: [ |
|
|
|
|
|
Colors.black.withOpacity(0.1), |
|
|
|
|
|
Colors.black.withOpacity(0.5), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
), |
|
|
), |
|
|
SizedBox(height: 8.h), |
|
|
|
|
|
// 视频时长 |
|
|
|
|
|
Container( |
|
|
|
|
|
|
|
|
child: Column( |
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center, |
|
|
|
|
|
children: [ |
|
|
|
|
|
// 播放按钮(增强动画效果) |
|
|
|
|
|
Container( |
|
|
|
|
|
padding: EdgeInsets.all(8.w), |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.white.withOpacity(0.2), |
|
|
|
|
|
shape: BoxShape.circle, |
|
|
|
|
|
), |
|
|
|
|
|
child: Icon( |
|
|
|
|
|
Icons.play_arrow_rounded, |
|
|
|
|
|
size: 48.w, |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
// 右下角显示时长标签 |
|
|
|
|
|
Positioned( |
|
|
|
|
|
right: 8.w, |
|
|
|
|
|
bottom: 8.h, |
|
|
|
|
|
child: Container( |
|
|
padding: EdgeInsets.symmetric( |
|
|
padding: EdgeInsets.symmetric( |
|
|
horizontal: 8.w, |
|
|
horizontal: 8.w, |
|
|
vertical: 4.h, |
|
|
vertical: 4.h, |
|
|
), |
|
|
), |
|
|
decoration: BoxDecoration( |
|
|
decoration: BoxDecoration( |
|
|
color: Colors.black.withOpacity(0.6), |
|
|
|
|
|
|
|
|
color: Colors.black.withOpacity(0.7), |
|
|
borderRadius: BorderRadius.circular(4.w), |
|
|
borderRadius: BorderRadius.circular(4.w), |
|
|
), |
|
|
), |
|
|
child: Text( |
|
|
|
|
|
_formatDuration(widget.videoBody.duration ?? 0), |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
fontSize: 12.sp, |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
fontWeight: FontWeight.w500, |
|
|
|
|
|
), |
|
|
|
|
|
|
|
|
child: Row( |
|
|
|
|
|
mainAxisSize: MainAxisSize.min, |
|
|
|
|
|
children: [ |
|
|
|
|
|
Icon( |
|
|
|
|
|
Icons.videocam_rounded, |
|
|
|
|
|
size: 12.w, |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
), |
|
|
|
|
|
SizedBox(width: 4.w), |
|
|
|
|
|
Text( |
|
|
|
|
|
_formatDuration(widget.videoBody.duration ?? 0), |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
fontSize: 11.sp, |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
fontWeight: FontWeight.w600, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
), |
|
|
), |
|
|
], |
|
|
|
|
|
|
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
|
|
|
|
|
|
// 🚀 加载指示器(播放时显示) |
|
|
|
|
|
if (_isLoadingVideo) |
|
|
|
|
|
Positioned.fill( |
|
|
|
|
|
child: Container( |
|
|
|
|
|
width: 200.w, |
|
|
|
|
|
height: 150.h, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.black.withOpacity(0.5), |
|
|
|
|
|
borderRadius: BorderRadius.circular(12.w), |
|
|
|
|
|
), |
|
|
|
|
|
child: Center( |
|
|
|
|
|
child: CircularProgressIndicator( |
|
|
|
|
|
strokeWidth: 3, |
|
|
|
|
|
valueColor: AlwaysStoppedAnimation<Color>( |
|
|
|
|
|
Colors.white, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
), |
|
|
), |
|
|
], |
|
|
], |
|
|
), |
|
|
), |
|
|
@ -256,36 +353,125 @@ class _VideoItemState extends State<VideoItem> { |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 构建缩略图 |
|
|
|
|
|
|
|
|
// 🚀 性能优化:构建缓存的缩略图 |
|
|
Widget _buildThumbnail() { |
|
|
Widget _buildThumbnail() { |
|
|
if (_thumbnailPath == null || _thumbnailPath!.isEmpty) { |
|
|
if (_thumbnailPath == null || _thumbnailPath!.isEmpty) { |
|
|
|
|
|
print('⚠️ [VideoItem] 缩略图路径为空,不渲染'); |
|
|
return const SizedBox.shrink(); |
|
|
return const SizedBox.shrink(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
print('🎨 [VideoItem] 开始渲染缩略图: $_thumbnailPath'); |
|
|
|
|
|
|
|
|
|
|
|
// 🎯 检查是否是视频预览模式 |
|
|
|
|
|
if (_thumbnailPath!.startsWith('video:')) { |
|
|
|
|
|
print('🎬 [VideoItem] 使用视频第一帧预览模式'); |
|
|
|
|
|
final videoPath = _thumbnailPath!.substring(6); // 移除 "video:" 前缀 |
|
|
|
|
|
return _buildVideoPreview(videoPath); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
// 判断是本地路径还是远程路径 |
|
|
// 判断是本地路径还是远程路径 |
|
|
if (_thumbnailPath!.startsWith('http')) { |
|
|
if (_thumbnailPath!.startsWith('http')) { |
|
|
return Image.network( |
|
|
|
|
|
_thumbnailPath!, |
|
|
|
|
|
|
|
|
print('🌐 [VideoItem] 渲染网络缩略图'); |
|
|
|
|
|
// 🚀 使用 CachedNetworkImage 缓存网络图片 |
|
|
|
|
|
return CachedNetworkImage( |
|
|
|
|
|
imageUrl: _thumbnailPath!, |
|
|
fit: BoxFit.cover, |
|
|
fit: BoxFit.cover, |
|
|
width: double.infinity, |
|
|
width: double.infinity, |
|
|
height: double.infinity, |
|
|
height: double.infinity, |
|
|
errorBuilder: (context, error, stackTrace) { |
|
|
|
|
|
|
|
|
placeholder: (context, url) { |
|
|
|
|
|
print('⏳ [VideoItem] 网络缩略图加载中...'); |
|
|
|
|
|
return Container( |
|
|
|
|
|
color: Colors.grey[300], |
|
|
|
|
|
child: Center( |
|
|
|
|
|
child: SizedBox( |
|
|
|
|
|
width: 24.w, |
|
|
|
|
|
height: 24.w, |
|
|
|
|
|
child: CircularProgressIndicator( |
|
|
|
|
|
strokeWidth: 2, |
|
|
|
|
|
color: Colors.grey[600], |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
); |
|
|
|
|
|
}, |
|
|
|
|
|
errorWidget: (context, url, error) { |
|
|
|
|
|
print('❌ [VideoItem] 网络缩略图加载失败: $error'); |
|
|
return const SizedBox.shrink(); |
|
|
return const SizedBox.shrink(); |
|
|
}, |
|
|
}, |
|
|
|
|
|
// 🚀 内存缓存配置 |
|
|
|
|
|
memCacheWidth: 400, // 限制缓存图片宽度 |
|
|
|
|
|
memCacheHeight: 300, // 限制缓存图片高度 |
|
|
); |
|
|
); |
|
|
} else { |
|
|
} else { |
|
|
|
|
|
print('📁 [VideoItem] 渲染本地缩略图'); |
|
|
|
|
|
// 本地文件 |
|
|
final file = File(_thumbnailPath!); |
|
|
final file = File(_thumbnailPath!); |
|
|
if (file.existsSync()) { |
|
|
if (file.existsSync()) { |
|
|
|
|
|
print('✅ [VideoItem] 本地缩略图文件存在,开始渲染'); |
|
|
return Image.file( |
|
|
return Image.file( |
|
|
file, |
|
|
file, |
|
|
fit: BoxFit.cover, |
|
|
fit: BoxFit.cover, |
|
|
width: double.infinity, |
|
|
width: double.infinity, |
|
|
height: double.infinity, |
|
|
height: double.infinity, |
|
|
|
|
|
// 🚀 设置缓存宽高,减少内存占用 |
|
|
|
|
|
cacheWidth: 400, |
|
|
|
|
|
cacheHeight: 300, |
|
|
|
|
|
errorBuilder: (context, error, stackTrace) { |
|
|
|
|
|
print('❌ [VideoItem] 本地缩略图渲染失败: $error'); |
|
|
|
|
|
return const SizedBox.shrink(); |
|
|
|
|
|
}, |
|
|
); |
|
|
); |
|
|
} else { |
|
|
} else { |
|
|
|
|
|
print('❌ [VideoItem] 本地缩略图文件不存在: $_thumbnailPath'); |
|
|
return const SizedBox.shrink(); |
|
|
return const SizedBox.shrink(); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 🎯 构建视频第一帧预览(备选方案) |
|
|
|
|
|
Widget _buildVideoPreview(String videoPath) { |
|
|
|
|
|
return FutureBuilder<VideoPlayerController>( |
|
|
|
|
|
future: _initVideoPreview(videoPath), |
|
|
|
|
|
builder: (context, snapshot) { |
|
|
|
|
|
if (snapshot.connectionState == ConnectionState.done && |
|
|
|
|
|
snapshot.hasData && |
|
|
|
|
|
snapshot.data!.value.isInitialized) { |
|
|
|
|
|
print('✅ [VideoItem] 视频预览加载成功'); |
|
|
|
|
|
// 🎯 使用 FittedBox 保持视频比例,避免变形 |
|
|
|
|
|
return FittedBox( |
|
|
|
|
|
fit: BoxFit.cover, |
|
|
|
|
|
child: SizedBox( |
|
|
|
|
|
width: snapshot.data!.value.size.width, |
|
|
|
|
|
height: snapshot.data!.value.size.height, |
|
|
|
|
|
child: VideoPlayer(snapshot.data!), |
|
|
|
|
|
), |
|
|
|
|
|
); |
|
|
|
|
|
} else { |
|
|
|
|
|
print('⏳ [VideoItem] 视频预览加载中...'); |
|
|
|
|
|
return Container( |
|
|
|
|
|
color: Colors.grey[300], |
|
|
|
|
|
child: Center( |
|
|
|
|
|
child: SizedBox( |
|
|
|
|
|
width: 24.w, |
|
|
|
|
|
height: 24.w, |
|
|
|
|
|
child: CircularProgressIndicator( |
|
|
|
|
|
strokeWidth: 2, |
|
|
|
|
|
color: Colors.grey[600], |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
}, |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 初始化视频预览 |
|
|
|
|
|
Future<VideoPlayerController> _initVideoPreview(String videoPath) async { |
|
|
|
|
|
final controller = VideoPlayerController.file(File(videoPath)); |
|
|
|
|
|
await controller.initialize(); |
|
|
|
|
|
await controller.seekTo(Duration.zero); // 定位到第一帧 |
|
|
|
|
|
return controller; |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|