Browse Source

添加发送视频,点击视频查看,点击图片查看

ios
Jolie 4 months ago
parent
commit
d8c9a4ce4f
13 changed files with 1719 additions and 225 deletions
  1. 31
      lib/controller/message/chat_controller.dart
  2. 32
      lib/im/im_manager.dart
  3. 7
      lib/pages/message/chat_page.dart
  4. 182
      lib/pages/message/image_viewer_page.dart
  5. 214
      lib/pages/message/video_player_page.dart
  6. 34
      lib/widget/message/chat_input_bar.dart
  7. 54
      lib/widget/message/image_item.dart
  8. 17
      lib/widget/message/message_item.dart
  9. 82
      lib/widget/message/more_options_view.dart
  10. 547
      lib/widget/message/video_input_view.dart
  11. 291
      lib/widget/message/video_item.dart
  12. 450
      pubspec.lock
  13. 3
      pubspec.yaml

31
lib/controller/message/chat_controller.dart

@ -110,6 +110,37 @@ class ChatController extends GetxController {
}
}
///
Future<bool> sendVideoMessage(String filePath, int duration) async {
try {
print('🎬 [ChatController] 准备发送视频消息');
print('视频路径: $filePath');
print('视频时长: $duration');
final message = await IMManager.instance.sendVideoMessage(
filePath,
userId,
duration,
);
if (message != null) {
print('✅ [ChatController] 视频消息创建成功');
print('消息类型: ${message.body.type}');
//
messages.insert(0, message);
update();
return true;
}
print('❌ [ChatController] 视频消息创建失败');
return false;
} catch (e) {
print('❌ [ChatController] 发送视频消息异常: $e');
if (Get.isLogEnable) {
Get.log('发送视频消息失败: $e');
}
return false;
}
}
///
Future<void> fetchMessages({bool loadMore = false}) async {
try {

32
lib/im/im_manager.dart

@ -212,6 +212,38 @@ class IMManager {
}
}
///
Future<EMMessage?> sendVideoMessage(
String videoPath,
String toChatUsername,
int duration,
) async {
try {
print('🎬 [IMManager] 创建视频消息');
print('视频路径: $videoPath');
print('接收用户: $toChatUsername');
print('视频时长: $duration');
//
final message = EMMessage.createVideoSendMessage(
targetId: toChatUsername,
filePath: videoPath,
duration: duration,
);
print('消息创建成功,消息类型: ${message.body.type}');
print('消息体是否为视频: ${message.body is EMVideoMessageBody}');
//
await EMClient.getInstance.chatManager.sendMessage(message);
print('✅ [IMManager] 视频消息发送成功');
return message;
} catch (e) {
print('❌ [IMManager] 发送视频消息失败: $e');
return null;
}
}
///
Future<List<EMConversation>> getConversations() async {
return EMClient.getInstance.chatManager.loadAllConversations();

7
lib/pages/message/chat_page.dart

@ -87,6 +87,13 @@ class ChatPage extends StatelessWidget {
//
await controller.sendVoiceMessage(filePath, seconds);
},
onVideoRecorded: (filePath, duration) async {
print('🎬 [ChatPage] 收到视频录制/选择回调');
print('文件路径: $filePath');
print('时长: $duration');
// /
await controller.sendVideoMessage(filePath, duration);
},
),
],
),

182
lib/pages/message/image_viewer_page.dart

@ -0,0 +1,182 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:extended_image/extended_image.dart';
import 'package:get/get.dart';
///
class ImageViewerPage extends StatelessWidget {
final String? imageUrl;
final String? imagePath;
const ImageViewerPage({
this.imageUrl,
this.imagePath,
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
//
Center(
child: _buildImage(),
),
//
Positioned(
top: MediaQuery.of(context).padding.top + 10,
right: 16,
child: GestureDetector(
onTap: () => Get.back(),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 24,
),
),
),
),
],
),
);
}
Widget _buildImage() {
//
if (imagePath != null && imagePath!.isNotEmpty) {
return ExtendedImage.file(
File(imagePath!),
fit: BoxFit.contain,
mode: ExtendedImageMode.gesture,
initGestureConfigHandler: (state) {
return GestureConfig(
minScale: 0.9,
animationMinScale: 0.7,
maxScale: 3.0,
animationMaxScale: 3.5,
speed: 1.0,
inertialSpeed: 100.0,
initialScale: 1.0,
inPageView: false,
initialAlignment: InitialAlignment.center,
);
},
loadStateChanged: (state) {
switch (state.extendedImageLoadState) {
case LoadState.loading:
return const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
);
case LoadState.completed:
return null; // null
case LoadState.failed:
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: Colors.white,
size: 48,
),
SizedBox(height: 16),
Text(
'图片加载失败',
style: TextStyle(color: Colors.white),
),
],
),
);
}
},
);
}
//
if (imageUrl != null && imageUrl!.isNotEmpty) {
return ExtendedImage.network(
imageUrl!,
fit: BoxFit.contain,
mode: ExtendedImageMode.gesture,
cache: true,
initGestureConfigHandler: (state) {
return GestureConfig(
minScale: 0.9,
animationMinScale: 0.7,
maxScale: 3.0,
animationMaxScale: 3.5,
speed: 1.0,
inertialSpeed: 100.0,
initialScale: 1.0,
inPageView: false,
initialAlignment: InitialAlignment.center,
);
},
loadStateChanged: (state) {
switch (state.extendedImageLoadState) {
case LoadState.loading:
return Center(
child: CircularProgressIndicator(
color: Colors.white,
value: state.loadingProgress?.expectedTotalBytes != null
? state.loadingProgress!.cumulativeBytesLoaded /
state.loadingProgress!.expectedTotalBytes!
: null,
),
);
case LoadState.completed:
return null; // null
case LoadState.failed:
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: Colors.white,
size: 48,
),
SizedBox(height: 16),
Text(
'图片加载失败',
style: TextStyle(color: Colors.white),
),
],
),
);
}
},
);
}
//
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_not_supported,
color: Colors.white,
size: 48,
),
SizedBox(height: 16),
Text(
'没有可显示的图片',
style: TextStyle(color: Colors.white),
),
],
),
);
}
}

214
lib/pages/message/video_player_page.dart

@ -0,0 +1,214 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import 'package:get/get.dart';
///
class VideoPlayerPage extends StatefulWidget {
final String videoPath;
final bool isNetwork;
const VideoPlayerPage({
required this.videoPath,
this.isNetwork = false,
super.key,
});
@override
State<VideoPlayerPage> createState() => _VideoPlayerPageState();
}
class _VideoPlayerPageState extends State<VideoPlayerPage> {
late VideoPlayerController _videoPlayerController;
ChewieController? _chewieController;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_initializePlayer();
}
Future<void> _initializePlayer() async {
try {
//
if (widget.isNetwork) {
_videoPlayerController = VideoPlayerController.networkUrl(
Uri.parse(widget.videoPath),
);
} else {
_videoPlayerController = VideoPlayerController.file(
File(widget.videoPath),
);
}
//
await _videoPlayerController.initialize();
// Chewie
_chewieController = ChewieController(
videoPlayerController: _videoPlayerController,
autoPlay: true,
looping: false,
aspectRatio: _videoPlayerController.value.aspectRatio,
//
showControls: true,
//
autoInitialize: true,
//
errorBuilder: (context, errorMessage) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.white,
size: 48,
),
const SizedBox(height: 16),
Text(
'播放失败\n$errorMessage',
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
],
),
);
},
//
allowFullScreen: true,
//
allowMuting: true,
//
allowPlaybackSpeedChanging: true,
//
playbackSpeeds: const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0],
//
materialProgressColors: ChewieProgressColors(
playedColor: Theme.of(context).primaryColor,
handleColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
bufferedColor: Colors.grey[300]!,
),
);
setState(() {
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
print('❌ 视频初始化失败: $e');
}
}
@override
void dispose() {
_chewieController?.dispose();
_videoPlayerController.dispose();
// UI
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
//
Center(
child: _isLoading
? const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
)
: _errorMessage != null
? _buildErrorWidget()
: _chewieController != null
? Chewie(controller: _chewieController!)
: const SizedBox.shrink(),
),
//
Positioned(
top: 16,
left: 16,
child: Material(
color: Colors.black.withOpacity(0.5),
shape: const CircleBorder(),
child: IconButton(
icon: const Icon(
Icons.close,
color: Colors.white,
size: 28,
),
onPressed: () => Get.back(),
),
),
),
],
),
),
);
}
Widget _buildErrorWidget() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.white,
size: 64,
),
const SizedBox(height: 16),
const Text(
'视频加载失败',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage ?? '未知错误',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_initializePlayer();
},
icon: const Icon(Icons.refresh),
label: const Text('重试'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
),
],
),
);
}
}

34
lib/widget/message/chat_input_bar.dart

@ -5,16 +5,19 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../generated/assets.dart';
import 'more_options_view.dart';
import 'voice_input_view.dart';
import 'video_input_view.dart';
class ChatInputBar extends StatefulWidget {
final ValueChanged<String> onSendMessage;
final ValueChanged<List<String>>? onImageSelected;
final Function(String filePath, int seconds)? onVoiceRecorded;
final Function(String filePath, int duration)? onVideoRecorded;
const ChatInputBar({
required this.onSendMessage,
this.onImageSelected,
this.onVoiceRecorded,
this.onVideoRecorded,
super.key,
});
@ -26,6 +29,7 @@ class _ChatInputBarState extends State<ChatInputBar> {
final TextEditingController _textController = TextEditingController();
bool _isMoreOptionsVisible = false;
bool _isVoiceVisible = false;
bool _isVideoVisible = false;
void _handleSendMessage() {
if (_textController.text.isNotEmpty) {
@ -36,10 +40,12 @@ class _ChatInputBarState extends State<ChatInputBar> {
//
void _toggleMoreOptions() {
print('📷 [ChatInputBar] 更多选项(图片)按钮被点击');
setState(() {
_isMoreOptionsVisible = !_isMoreOptionsVisible;
if (_isMoreOptionsVisible) {
_isVoiceVisible = false;
_isVideoVisible = false;
}
//
FocusManager.instance.primaryFocus?.unfocus();
@ -65,11 +71,25 @@ class _ChatInputBarState extends State<ChatInputBar> {
_isVoiceVisible = !_isVoiceVisible;
if (_isVoiceVisible) {
_isMoreOptionsVisible = false;
_isVideoVisible = false;
}
FocusManager.instance.primaryFocus?.unfocus();
});
}
void _toggleVideoOptions() {
print('🎬 [ChatInputBar] 视频按钮被点击,当前状态: $_isVideoVisible');
setState(() {
_isVideoVisible = !_isVideoVisible;
if (_isVideoVisible) {
_isMoreOptionsVisible = false;
_isVoiceVisible = false;
}
FocusManager.instance.primaryFocus?.unfocus();
});
print('🎬 [ChatInputBar] 视频面板状态改变为: $_isVideoVisible');
}
@override
Widget build(BuildContext context) {
return Column(
@ -139,7 +159,11 @@ class _ChatInputBarState extends State<ChatInputBar> {
height: 24.w,
).onTap(_toggleVoiceOptions),
//
Image.asset(Assets.imagesVideo, width: 24.w, height: 24.w),
Image.asset(
Assets.imagesVideo,
width: 24.w,
height: 24.w,
).onTap(_toggleVideoOptions),
//
Image.asset(Assets.imagesGift, width: 24.w, height: 24.w),
//
@ -155,17 +179,23 @@ class _ChatInputBarState extends State<ChatInputBar> {
],
),
),
//
//
MoreOptionsView(
isVisible: _isMoreOptionsVisible,
onImageSelected: _handleImageTap,
onCameraSelected: _handleCameraTap,
onVideoSelected: widget.onVideoRecorded,
),
// MoreOptionsView
VoiceInputView(
isVisible: _isVoiceVisible,
onVoiceRecorded: widget.onVoiceRecorded,
),
//
VideoInputView(
isVisible: _isVideoVisible,
onVideoRecorded: widget.onVideoRecorded,
),
],
);
}

54
lib/widget/message/image_item.dart

@ -1,10 +1,10 @@
import 'dart:io';
import 'package:dating_touchme_app/generated/assets.dart';
import 'package:dating_touchme_app/pages/message/image_viewer_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:im_flutter_sdk/im_flutter_sdk.dart';
import 'package:retrofit/http.dart';
class ImageItem extends StatelessWidget {
final EMImageMessageBody imageBody;
@ -37,18 +37,21 @@ class ImageItem extends StatelessWidget {
children: [
if (!isSentByMe) _buildAvatar(),
if (!isSentByMe) SizedBox(width: 8.w),
Container(
margin: EdgeInsets.only(top: 10.h),
decoration: BoxDecoration(
color: isSentByMe ? Color(0xff8E7BF6) : Colors.white,
borderRadius: BorderRadius.only(
topLeft: isSentByMe ? Radius.circular(12.w) : Radius.circular(0),
topRight: isSentByMe ? Radius.circular(0) : Radius.circular(12.w),
bottomLeft: Radius.circular(12.w),
bottomRight: Radius.circular(12.w),
GestureDetector(
onTap: _onImageTap,
child: Container(
margin: EdgeInsets.only(top: 10.h),
decoration: BoxDecoration(
color: isSentByMe ? Color(0xff8E7BF6) : Colors.white,
borderRadius: BorderRadius.only(
topLeft: isSentByMe ? Radius.circular(12.w) : Radius.circular(0),
topRight: isSentByMe ? Radius.circular(0) : Radius.circular(12.w),
bottomLeft: Radius.circular(12.w),
bottomRight: Radius.circular(12.w),
),
),
child: _buildImage(),
),
child: _buildImage(),
),
if (isSentByMe) SizedBox(width: 8.w),
if (isSentByMe) _buildAvatar(),
@ -117,10 +120,11 @@ class ImageItem extends StatelessWidget {
width = maxHeight * aspectRatio;
}
}
//
if (imageBody.localPath != null && imageBody.localPath!.isNotEmpty) {
//
final localPath = imageBody.localPath;
if (localPath.isNotEmpty) {
return Image.file(
File(imageBody.localPath),
File(localPath),
width: width,
height: height,
fit: BoxFit.cover,
@ -131,9 +135,10 @@ class ImageItem extends StatelessWidget {
}
//
if (imageBody.remotePath != null && imageBody.remotePath!.isNotEmpty) {
final remotePath = imageBody.remotePath;
if (remotePath != null && remotePath.isNotEmpty) {
return Image.network(
imageBody.remotePath!,
remotePath,
width: width,
height: height,
fit: BoxFit.cover,
@ -187,4 +192,21 @@ class ImageItem extends StatelessWidget {
),
);
}
//
void _onImageTap() {
// 使使
String? imagePath = imageBody.localPath;
String? imageUrl = imageBody.remotePath;
//
Get.to(
() => ImageViewerPage(
imagePath: imagePath,
imageUrl: imageUrl,
),
transition: Transition.fade,
duration: const Duration(milliseconds: 300),
);
}
}

17
lib/widget/message/message_item.dart

@ -5,6 +5,7 @@ import 'package:im_flutter_sdk/im_flutter_sdk.dart';
import 'text_item.dart';
import 'image_item.dart';
import 'voice_item.dart';
import 'video_item.dart';
class MessageItem extends StatelessWidget {
final EMMessage message;
@ -20,6 +21,8 @@ class MessageItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
print('📨 [MessageItem] 渲染消息,类型: ${message.body.type}');
//
if (message.body.type == MessageType.TXT) {
final textBody = message.body as EMTextMessageBody;
@ -50,14 +53,26 @@ class MessageItem extends StatelessWidget {
formattedTime: formatMessageTime(message.serverTime),
);
}
//
else if (message.body.type == MessageType.VIDEO) {
print('🎬 [MessageItem] 检测到视频消息,准备渲染 VideoItem');
final videoBody = message.body as EMVideoMessageBody;
return VideoItem(
videoBody: videoBody,
isSentByMe: isSentByMe,
showTime: shouldShowTime(),
formattedTime: formatMessageTime(message.serverTime),
);
}
//
print('⚠️ [MessageItem] 不支持的消息类型: ${message.body.type}');
return Column(
children: [
if (shouldShowTime()) _buildTimeLabel(),
Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
child: Text('不支持的消息类型'),
child: Text('不支持的消息类型: ${message.body.type}'),
),
],
);

82
lib/widget/message/more_options_view.dart

@ -1,8 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:wechat_camera_picker/wechat_camera_picker.dart';
import 'package:video_player/video_player.dart';
import '../../generated/assets.dart';
@ -10,11 +12,13 @@ class MoreOptionsView extends StatelessWidget {
final bool isVisible;
final ValueChanged<List<String>> onImageSelected;
final ValueChanged<String> onCameraSelected;
final Function(String filePath, int duration)? onVideoSelected;
const MoreOptionsView({
required this.isVisible,
required this.onImageSelected,
required this.onCameraSelected,
this.onVideoSelected,
super.key,
});
@ -34,25 +38,53 @@ class MoreOptionsView extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
//
// /
GestureDetector(
onTap: () async{
try {
List<AssetEntity>? result = await AssetPicker.pickAssets(context);
print('📷 [MoreOptionsView] 打开相册选择图片/视频');
//
List<AssetEntity>? result = await AssetPicker.pickAssets(
context,
pickerConfig: const AssetPickerConfig(
requestType: RequestType.common, //
maxAssets: 9,
),
);
if (result != null && result.isNotEmpty) {
//
print('选择了 ${result.length} 个文件');
//
List<String> imagePaths = [];
for (var asset in result) {
final file = await asset.file;
if (file != null) {
imagePaths.add(file.path);
print('文件类型: ${asset.type}, 路径: ${file.path}');
if (asset.type == AssetType.video) {
//
print('检测到视频文件');
final duration = asset.duration;
if (onVideoSelected != null) {
onVideoSelected!(file.path, duration);
}
} else {
//
imagePaths.add(file.path);
}
}
}
//
if (imagePaths.isNotEmpty) {
print('发送 ${imagePaths.length} 张图片');
onImageSelected(imagePaths);
}
}
} catch (e) {
print('❌ 选择文件失败: $e');
if (Get.isLogEnable) {
Get.log("选择图片失败: $e");
}
@ -72,7 +104,7 @@ class MoreOptionsView extends StatelessWidget {
),
SizedBox(height: 8.h),
Text(
"图片",
"相册",
style: TextStyle(
fontSize: 12.sp,
color: Colors.black,
@ -82,22 +114,40 @@ class MoreOptionsView extends StatelessWidget {
),
),
SizedBox(width: 40.w),
//
//
GestureDetector(
onTap: () async{
try {
print('📷 [MoreOptionsView] 打开相机');
//
AssetEntity? entity = await CameraPicker.pickFromCamera(
context,
pickerConfig: const CameraPickerConfig(),
pickerConfig: const CameraPickerConfig(
enableRecording: true, //
),
);
if (entity != null) {
//
final file = await entity.file;
if (file != null) {
onCameraSelected(file.path);
print('文件类型: ${entity.type}, 路径: ${file.path}');
if (entity.type == AssetType.video) {
//
print('检测到视频文件');
final duration = await _getVideoDuration(file.path);
if (onVideoSelected != null) {
onVideoSelected!(file.path, duration);
}
} else {
//
print('检测到图片文件');
onCameraSelected(file.path);
}
}
}
} catch (e) {
print('❌ 相机操作失败: $e');
if (Get.isLogEnable) {
Get.log("拍照失败: $e");
}
@ -134,4 +184,18 @@ class MoreOptionsView extends StatelessWidget {
: null,
);
}
//
Future<int> _getVideoDuration(String filePath) async {
try {
final controller = VideoPlayerController.file(File(filePath));
await controller.initialize();
final duration = controller.value.duration.inSeconds;
await controller.dispose();
return duration;
} catch (e) {
print('获取视频时长失败: $e');
return 0;
}
}
}

547
lib/widget/message/video_input_view.dart

@ -0,0 +1,547 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.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';
import 'package:video_player/video_player.dart';
class VideoInputView extends StatefulWidget {
final bool isVisible;
final Function(String filePath, int duration)? onVideoRecorded;
const VideoInputView({
required this.isVisible,
this.onVideoRecorded,
super.key,
});
@override
State<VideoInputView> createState() => _VideoInputViewState();
}
class _VideoInputViewState extends State<VideoInputView> {
Timer? _timer;
int _seconds = 0;
bool _isRecording = false;
bool _isCanceling = false;
Offset _panStartPosition = Offset.zero;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
//
String _formatTime(int seconds) {
final minutes = seconds ~/ 60;
final secs = seconds % 60;
return '$minutes:${secs.toString().padLeft(2, '0')}';
}
//
Future<void> _startRecording() async {
//
final cameraStatus = await Permission.camera.request();
final micStatus = await Permission.microphone.request();
if (!cameraStatus.isGranted || !micStatus.isGranted) {
Get.snackbar('提示', '需要相机和麦克风权限才能录制视频');
return;
}
setState(() {
_isRecording = true;
_seconds = 0;
_isCanceling = false;
_panStartPosition = Offset.zero;
});
//
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
_seconds++;
});
});
}
//
Future<void> _stopRecording({bool cancel = false}) async {
_timer?.cancel();
_timer = null;
if (_isRecording) {
final finalSeconds = _seconds;
setState(() {
_isRecording = false;
_seconds = 0;
_isCanceling = false;
_panStartPosition = Offset.zero;
});
//
if (!cancel && finalSeconds > 0) {
_openCameraForVideo();
}
}
}
//
Future<void> _openCameraForVideo() async {
try {
print('🎬 [VideoInputView] 打开相机录制视频');
AssetEntity? entity = await CameraPicker.pickFromCamera(
Get.context!,
pickerConfig: const CameraPickerConfig(
enableRecording: true,
onlyEnableRecording: true,
),
);
print('🎬 [VideoInputView] 录制结果: ${entity != null ? "成功" : "取消"}');
if (entity != null) {
print('资源类型: ${entity.type}');
print('资源时长: ${entity.duration}');
final file = await entity.file;
if (file != null) {
print('✅ [VideoInputView] 视频文件获取成功');
print('文件路径: ${file.path}');
//
final duration = await _getVideoDuration(file.path);
print('视频时长: $duration');
widget.onVideoRecorded?.call(file.path, duration);
}
}
} catch (e) {
print('❌ [VideoInputView] 录制视频失败: $e');
if (Get.isLogEnable) {
Get.log("录制视频失败: $e");
}
}
}
//
Future<void> _pickVideoFromGallery() async {
try {
print('🎬 [VideoInputView] 开始选择视频');
List<AssetEntity>? result = await AssetPicker.pickAssets(
Get.context!,
pickerConfig: const AssetPickerConfig(
maxAssets: 1,
requestType: RequestType.video,
specialPickerType: SpecialPickerType.noPreview,
),
);
print('🎬 [VideoInputView] 选择结果: ${result?.length ?? 0} 个文件');
if (result != null && result.isNotEmpty) {
final asset = result.first;
print('资源类型: ${asset.type}');
print('资源时长: ${asset.duration}');
final file = await asset.file;
if (file != null) {
print('✅ [VideoInputView] 视频文件获取成功');
print('文件路径: ${file.path}');
print('文件大小: ${file.lengthSync()} 字节');
//
final duration = asset.duration;
print('准备回调,时长: $duration');
widget.onVideoRecorded?.call(file.path, duration);
} else {
print('❌ [VideoInputView] 文件为空');
}
} else {
print('⚠️ [VideoInputView] 用户取消选择或未选择任何文件');
}
} catch (e) {
print('❌ [VideoInputView] 选择视频失败: $e');
if (Get.isLogEnable) {
Get.log("选择视频失败: $e");
}
Get.snackbar('错误', '选择视频失败: $e');
}
}
//
Future<int> _getVideoDuration(String filePath) async {
try {
final controller = VideoPlayerController.file(File(filePath));
await controller.initialize();
final duration = controller.value.duration.inSeconds;
await controller.dispose();
return duration;
} catch (e) {
print('获取视频时长失败: $e');
return 0;
}
}
//
void _onLongPressStart(LongPressStartDetails details) {
_panStartPosition = details.globalPosition;
_startRecording();
}
//
void _onLongPressEnd() {
if (_isCanceling) {
_stopRecording(cancel: true);
} else {
_stopRecording(cancel: false);
}
}
//
Widget _buildWaveformWithTimer() {
const int leftBars = 15;
const int rightBars = 15;
const int purpleBarsPerSide = 2;
return Container(
height: 30.h,
width: double.infinity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
//
...List.generate(leftBars, (index) {
final isNearTimer = index >= leftBars - purpleBarsPerSide;
final isPurple = _isRecording && isNearTimer;
if (!_isRecording) {
return Container(
width: 2.5.w,
height: 6.h,
margin: EdgeInsets.symmetric(horizontal: 0.8.w),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.3),
borderRadius: BorderRadius.circular(1.25.w),
),
);
}
final random = (index * 7 + _seconds * 3) % 10;
final isActive = isPurple || random > 5;
final baseHeight = isActive
? (isPurple ? 12 + random : 8 + random)
: 6;
final height = (baseHeight.clamp(6, 20)).h;
final color = isPurple
? const Color(0xFF8359FF)
: isActive
? Colors.grey.withOpacity(0.5)
: Colors.grey.withOpacity(0.3);
return AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
width: 2.5.w,
height: height,
margin: EdgeInsets.symmetric(horizontal: 0.8.w),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(1.25.w),
),
);
}),
//
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w),
child: Text(
_isRecording ? _formatTime(_seconds) : '0:00',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: _isRecording ? Colors.black87 : Colors.grey,
),
),
),
//
...List.generate(rightBars, (index) {
final isNearTimer = index < purpleBarsPerSide;
final isPurple = _isRecording && isNearTimer;
if (!_isRecording) {
return Container(
width: 2.5.w,
height: 6.h,
margin: EdgeInsets.symmetric(horizontal: 0.8.w),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.3),
borderRadius: BorderRadius.circular(1.25.w),
),
);
}
final random = (index * 7 + _seconds * 3) % 10;
final isActive = isPurple || random > 5;
final baseHeight = isActive
? (isPurple ? 12 + random : 8 + random)
: 6;
final height = (baseHeight.clamp(6, 20)).h;
final color = isPurple
? const Color(0xFF8359FF)
: isActive
? Colors.grey.withOpacity(0.5)
: Colors.grey.withOpacity(0.3);
return AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
width: 2.5.w,
height: height,
margin: EdgeInsets.symmetric(horizontal: 0.8.w),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(1.25.w),
),
);
}),
],
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: widget.isVisible ? 180.h : 0,
color: Colors.white,
child: widget.isVisible
? Container(
width: 1.sw,
color: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Stack(
alignment: Alignment.center,
children: [
// -
if (_isRecording && !_isCanceling)
Positioned(
top: 0,
left: -16.w,
right: -16.w,
child: _buildWaveformWithTimer(),
),
//
if (_isCanceling)
Positioned(
top: 0,
left: 0,
right: 0,
child: Center(
child: Container(
width: 38.w,
height: 38.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.red.shade400,
boxShadow: [
BoxShadow(
color: Colors.red.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Center(
child: Icon(
Icons.close_rounded,
color: Colors.white,
size: 20.w,
),
),
),
),
),
//
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: 16.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
//
GestureDetector(
onTap: _pickVideoFromGallery,
child: Column(
children: [
Container(
width: 70.w,
height: 70.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: const [
Color(0xFF8359FF),
Color(0xFF3D8AE0),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF8359FF)
.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Center(
child: Icon(
Icons.photo_library_rounded,
color: Colors.white,
size: 40.w,
),
),
),
SizedBox(height: 8.h),
Text(
'相册',
style: TextStyle(
fontSize: 14.sp,
color: Colors.black.withOpacity(0.6),
),
),
],
),
),
//
Listener(
onPointerDown: (details) {
_panStartPosition = details.position;
},
onPointerMove: (details) {
if (_isRecording) {
if (_panStartPosition == Offset.zero) {
_panStartPosition = details.position;
return;
}
final deltaY =
_panStartPosition.dy - details.position.dy;
final shouldCancel = deltaY > 60;
if (_isCanceling != shouldCancel) {
setState(() {
_isCanceling = shouldCancel;
});
}
}
},
onPointerUp: (_) {
if (_isRecording) {
_onLongPressEnd();
}
},
child: GestureDetector(
onLongPressStart: _onLongPressStart,
onLongPressEnd: (_) => _onLongPressEnd(),
behavior: HitTestBehavior.opaque,
child: Column(
children: [
Stack(
alignment: Alignment.center,
children: [
//
if (_isRecording && !_isCanceling)
Container(
width: 80.w,
height: 80.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF8359FF)
.withOpacity(0.3),
),
),
//
Container(
width: _isRecording ? 70.w : 80.w,
height: _isRecording ? 70.w : 80.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: _isCanceling
? LinearGradient(
colors: [
Colors.grey.shade300,
Colors.grey.shade400,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: LinearGradient(
colors: const [
Color(0xFF8359FF),
Color(0xFF3D8AE0),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: _isCanceling
? Colors.grey.withOpacity(0.3)
: const Color(0xFF8359FF)
.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Center(
child: Icon(
Icons.videocam_rounded,
color: Colors.white,
size: 50.w,
),
),
),
],
),
SizedBox(height: 8.h),
Text(
_isCanceling
? '松开取消'
: _isRecording
? '松开发送,上滑取消'
: '长按拍摄',
style: TextStyle(
fontSize: 14.sp,
color: _isCanceling
? Colors.red
: Colors.black.withOpacity(0.6),
),
),
],
),
),
),
],
),
],
),
],
),
)
: null,
);
}
}

291
lib/widget/message/video_item.dart

@ -0,0 +1,291 @@
import 'dart:io';
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:video_player/video_player.dart';
import 'package:dating_touchme_app/pages/message/video_player_page.dart';
class VideoItem extends StatefulWidget {
final EMVideoMessageBody videoBody;
final bool isSentByMe;
final bool showTime;
final String formattedTime;
const VideoItem({
required this.videoBody,
required this.isSentByMe,
required this.showTime,
required this.formattedTime,
super.key,
});
@override
State<VideoItem> createState() => _VideoItemState();
}
class _VideoItemState extends State<VideoItem> {
VideoPlayerController? _controller;
bool _isInitialized = false;
String? _thumbnailPath;
@override
void initState() {
super.initState();
_initializeVideo();
}
Future<void> _initializeVideo() async {
try {
//
final localPath = widget.videoBody.localPath;
final remotePath = widget.videoBody.remotePath;
print('=== 视频消息调试信息 ===');
print('本地路径: $localPath');
print('远程路径: $remotePath');
print('视频时长: ${widget.videoBody.duration}');
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();
setState(() {
_isInitialized = true;
});
print('✅ 视频初始化成功');
}
//
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('======================');
} catch (e) {
print('❌ 初始化视频失败: $e');
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
//
Widget _buildTimeLabel() {
return Container(
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
child: Text(
widget.formattedTime,
style: TextStyle(fontSize: 12.sp, color: Colors.grey),
),
),
);
}
//
String _formatDuration(int seconds) {
final minutes = seconds ~/ 60;
final secs = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
//
void _playVideo() {
//
final localPath = widget.videoBody.localPath;
final remotePath = widget.videoBody.remotePath;
String? videoPath;
bool isNetwork = false;
// 使
if (localPath.isNotEmpty && File(localPath).existsSync()) {
videoPath = localPath;
isNetwork = false;
} else if (remotePath != null && remotePath.isNotEmpty) {
videoPath = remotePath;
isNetwork = true;
}
if (videoPath != null) {
// 使 Chewie
Get.to(
() => VideoPlayerPage(
videoPath: videoPath!,
isNetwork: isNetwork,
),
transition: Transition.fade,
duration: const Duration(milliseconds: 200),
);
} else {
Get.snackbar(
'提示',
'视频路径不可用',
snackPosition: SnackPosition.BOTTOM,
);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
if (widget.showTime) _buildTimeLabel(),
Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 4.h),
child: Row(
mainAxisAlignment: widget.isSentByMe
? MainAxisAlignment.end
: 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],
),
),
//
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: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//
Icon(
Icons.play_circle_filled,
size: 56.w,
color: Colors.white.withOpacity(0.9),
),
SizedBox(height: 8.h),
//
Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(4.w),
),
child: Text(
_formatDuration(widget.videoBody.duration ?? 0),
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
),
),
),
],
),
),
],
);
}
//
Widget _buildThumbnail() {
if (_thumbnailPath == null || _thumbnailPath!.isEmpty) {
return const SizedBox.shrink();
}
//
if (_thumbnailPath!.startsWith('http')) {
return Image.network(
_thumbnailPath!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (context, error, stackTrace) {
return const SizedBox.shrink();
},
);
} else {
final file = File(_thumbnailPath!);
if (file.existsSync()) {
return Image.file(
file,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
);
} else {
return const SizedBox.shrink();
}
}
}
}

450
pubspec.lock
File diff suppressed because it is too large
View File

3
pubspec.yaml

@ -54,10 +54,13 @@ dependencies:
flustars: ^2.0.1
easy_refresh: ^3.4.0
cached_network_image: ^3.4.1 # 图片加载
extended_image: ^9.0.4 # 图片查看器
wechat_assets_picker: ^9.8.0
wechat_camera_picker: ^4.4.0
tdesign_flutter: ^0.2.5
record: ^6.1.2
video_player: ^2.9.2
chewie: ^1.8.5 # 视频播放器UI
dev_dependencies:
flutter_test:

Loading…
Cancel
Save