Browse Source

优化视频消息

ios
Jolie 4 months ago
parent
commit
60399119d9
7 changed files with 476 additions and 126 deletions
  1. 47
      lib/controller/message/chat_controller.dart
  2. 49
      lib/im/im_manager.dart
  3. 58
      lib/pages/message/chat_page.dart
  4. 5
      lib/widget/message/video_input_view.dart
  5. 434
      lib/widget/message/video_item.dart
  6. 8
      pubspec.lock
  7. 1
      pubspec.yaml

47
lib/controller/message/chat_controller.dart

@ -1,4 +1,5 @@
import 'package:get/get.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import '../../im/im_manager.dart';
import 'package:im_flutter_sdk/im_flutter_sdk.dart';
@ -16,6 +17,10 @@ class ChatController extends GetxController {
//
String? _cursor;
//
final RxBool isSendingVideo = RxBool(false);
final RxString sendingStatus = RxString('');
ChatController({required this.userId});
@override
@ -128,34 +133,76 @@ class ChatController extends GetxController {
///
Future<bool> sendVideoMessage(String filePath, int duration) async {
//
if (isSendingVideo.value) {
SmartDialog.showToast('视频正在发送中,请稍候...');
return false;
}
try {
//
isSendingVideo.value = true;
sendingStatus.value = '正在准备视频...';
update();
print('🎬 [ChatController] 准备发送视频消息');
print('视频路径: $filePath');
print('视频时长: $duration');
sendingStatus.value = '正在上传视频...';
update();
final message = await IMManager.instance.sendVideoMessage(
filePath,
userId,
duration,
);
if (message != null) {
print('✅ [ChatController] 视频消息创建成功');
print('消息类型: ${message.body.type}');
sendingStatus.value = '发送成功';
update();
//
messages.insert(0, message);
update();
//
_refreshConversationList();
//
SmartDialog.showToast('✅ 视频发送成功');
return true;
}
print('❌ [ChatController] 视频消息创建失败');
sendingStatus.value = '发送失败';
update();
SmartDialog.showToast('❌ 视频消息发送失败,请重试');
return false;
} catch (e) {
print('❌ [ChatController] 发送视频消息异常: $e');
sendingStatus.value = '发送失败: $e';
update();
if (Get.isLogEnable) {
Get.log('发送视频消息失败: $e');
}
SmartDialog.showToast('❌ 视频消息发送失败: ${e.toString()}');
return false;
} finally {
//
isSendingVideo.value = false;
sendingStatus.value = '';
update();
}
}

49
lib/im/im_manager.dart

@ -1,6 +1,9 @@
import 'dart:io';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:im_flutter_sdk/im_flutter_sdk.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
import 'package:path_provider/path_provider.dart';
import '../controller/message/conversation_controller.dart';
import '../controller/message/chat_controller.dart';
@ -236,15 +239,61 @@ class IMManager {
print('接收用户: $toChatUsername');
print('视频时长: $duration');
// 🎯
String? thumbnailPath;
try {
print('📸 [IMManager] 开始生成视频缩略图...');
//
final tempDir = await getTemporaryDirectory();
final fileName = videoPath.split('/').last.split('.').first;
final thumbFileName = '${fileName}_thumb.jpg';
thumbnailPath = '${tempDir.path}/$thumbFileName';
// 使 video_thumbnail
final uint8list = await VideoThumbnail.thumbnailFile(
video: videoPath,
thumbnailPath: thumbnailPath,
imageFormat: ImageFormat.JPEG,
maxWidth: 400, //
quality: 75, //
);
if (uint8list != null && File(uint8list).existsSync()) {
thumbnailPath = uint8list;
print('✅ [IMManager] 缩略图生成成功: $thumbnailPath');
} else {
print('⚠️ [IMManager] 缩略图生成返回null');
thumbnailPath = null;
}
} catch (e) {
print('❌ [IMManager] 生成缩略图失败: $e');
thumbnailPath = null;
}
//
final message = EMMessage.createVideoSendMessage(
targetId: toChatUsername,
filePath: videoPath,
duration: duration,
thumbnailLocalPath: thumbnailPath, // 🎯
);
print('消息创建成功,消息类型: ${message.body.type}');
print('消息体是否为视频: ${message.body is EMVideoMessageBody}');
//
if (message.body is EMVideoMessageBody) {
final videoBody = message.body as EMVideoMessageBody;
print('📸 [IMManager] 缩略图本地路径: ${videoBody.thumbnailLocalPath}');
print('📸 [IMManager] 缩略图远程路径: ${videoBody.thumbnailRemotePath}');
//
if (videoBody.thumbnailLocalPath != null) {
final thumbFile = File(videoBody.thumbnailLocalPath!);
print('📸 [IMManager] 缩略图文件是否存在: ${thumbFile.existsSync()}');
}
}
//
await EMClient.getInstance.chatManager.sendMessage(message);

58
lib/pages/message/chat_page.dart

@ -79,6 +79,53 @@ class _ChatPageState extends State<ChatPage> {
),
body: Column(
children: [
//
Obx(() {
if (controller.isSendingVideo.value) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 12.h,
),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
border: Border(
bottom: BorderSide(
color: Colors.blue.withOpacity(0.3),
width: 1,
),
),
),
child: Row(
children: [
SizedBox(
width: 20.w,
height: 20.w,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue,
),
),
),
SizedBox(width: 12.w),
Expanded(
child: Text(
controller.sendingStatus.value,
style: TextStyle(
fontSize: 14.sp,
color: Colors.blue[700],
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
return SizedBox.shrink();
}),
//
Expanded(
child: Container(
@ -94,6 +141,8 @@ class _ChatPageState extends State<ChatPage> {
reverse: true,
padding: EdgeInsets.all(16.w),
itemCount: controller.messages.length,
// 🚀
cacheExtent: 500, // 500
itemBuilder: (context, index) {
final message = controller.messages[index];
final isSentByMe =
@ -103,7 +152,9 @@ class _ChatPageState extends State<ChatPage> {
? controller.messages[index - 1]
: null;
// 🚀 key
return MessageItem(
key: ValueKey(message.msgId),
message: message,
isSentByMe: isSentByMe,
previousMessage: previousMessage,
@ -132,6 +183,13 @@ class _ChatPageState extends State<ChatPage> {
print('🎬 [ChatPage] 收到视频录制/选择回调');
print('文件路径: $filePath');
print('时长: $duration');
//
if (controller.isSendingVideo.value) {
print('⚠️ [ChatPage] 视频正在发送中,忽略新的发送请求');
return;
}
// /
await controller.sendVideoMessage(filePath, duration);
},

5
lib/widget/message/video_input_view.dart

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:wechat_camera_picker/wechat_camera_picker.dart';
@ -49,7 +50,7 @@ class _VideoInputViewState extends State<VideoInputView> {
final micStatus = await Permission.microphone.request();
if (!cameraStatus.isGranted || !micStatus.isGranted) {
Get.snackbar('提示', '需要相机和麦克风权限才能录制视频');
SmartDialog.showToast('需要相机和麦克风权限才能录制视频');
return;
}
@ -170,7 +171,7 @@ class _VideoInputViewState extends State<VideoInputView> {
if (Get.isLogEnable) {
Get.log("选择视频失败: $e");
}
Get.snackbar('错误', '选择视频失败: $e');
SmartDialog.showToast('选择视频失败: $e');
}
}

434
lib/widget/message/video_item.dart

@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:im_flutter_sdk/im_flutter_sdk.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:dating_touchme_app/pages/message/video_player_page.dart';
@ -24,73 +26,96 @@ class VideoItem extends StatefulWidget {
State<VideoItem> createState() => _VideoItemState();
}
//
enum MessageSendStatus {
sending,
success,
failed,
}
class _VideoItemState extends State<VideoItem> {
VideoPlayerController? _controller;
bool _isInitialized = false;
String? _thumbnailPath;
bool _isLoadingVideo = false;
@override
void 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 {
//
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(() {
_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) {
print('初始化视频失败: $e');
print('❌ [VideoItem] 生成视频预览失败: $e');
}
}
@override
void dispose() {
_controller?.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 remotePath = widget.videoBody.remotePath;
@ -135,8 +165,18 @@ class _VideoItemState extends State<VideoItem> {
}
if (videoPath != null) {
//
if (isNetwork) {
setState(() {
_isLoadingVideo = true;
});
//
await Future.delayed(Duration(milliseconds: 300));
}
// 使 Chewie
Get.to(
await Get.to(
() => VideoPlayerPage(
videoPath: videoPath!,
isNetwork: isNetwork,
@ -144,12 +184,15 @@ class _VideoItemState extends State<VideoItem> {
transition: Transition.fade,
duration: const Duration(milliseconds: 200),
);
//
if (mounted && isNetwork) {
setState(() {
_isLoadingVideo = false;
});
}
} else {
Get.snackbar(
'提示',
'视频路径不可用',
snackPosition: SnackPosition.BOTTOM,
);
SmartDialog.showToast('⚠️ 视频路径不可用,请稍后重试');
}
}
@ -166,88 +209,142 @@ class _VideoItemState extends State<VideoItem> {
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
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(
horizontal: 8.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
color: Colors.black.withOpacity(0.7),
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() {
if (_thumbnailPath == null || _thumbnailPath!.isEmpty) {
print('⚠️ [VideoItem] 缩略图路径为空,不渲染');
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')) {
return Image.network(
_thumbnailPath!,
print('🌐 [VideoItem] 渲染网络缩略图');
// 🚀 使 CachedNetworkImage
return CachedNetworkImage(
imageUrl: _thumbnailPath!,
fit: BoxFit.cover,
width: 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();
},
// 🚀
memCacheWidth: 400, //
memCacheHeight: 300, //
);
} else {
print('📁 [VideoItem] 渲染本地缩略图');
//
final file = File(_thumbnailPath!);
if (file.existsSync()) {
print('✅ [VideoItem] 本地缩略图文件存在,开始渲染');
return Image.file(
file,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
// 🚀
cacheWidth: 400,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) {
print('❌ [VideoItem] 本地缩略图渲染失败: $error');
return const SizedBox.shrink();
},
);
} else {
print('❌ [VideoItem] 本地缩略图文件不存在: $_thumbnailPath');
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;
}
}

8
pubspec.lock

@ -1570,6 +1570,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
video_thumbnail:
dependency: "direct main"
description:
name: video_thumbnail
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
visibility_detector:
dependency: transitive
description:

1
pubspec.yaml

@ -62,6 +62,7 @@ dependencies:
video_player: ^2.9.2
chewie: ^1.8.5 # 视频播放器UI
audioplayers: ^6.5.1
video_thumbnail: ^0.5.3 # 视频缩略图生成
fluwx: ^5.7.5
tobias: ^5.3.1
agora_rtc_engine: ^6.5.3

Loading…
Cancel
Save