13 changed files with 1719 additions and 225 deletions
Unified View
Diff Options
-
31lib/controller/message/chat_controller.dart
-
32lib/im/im_manager.dart
-
7lib/pages/message/chat_page.dart
-
182lib/pages/message/image_viewer_page.dart
-
214lib/pages/message/video_player_page.dart
-
34lib/widget/message/chat_input_bar.dart
-
54lib/widget/message/image_item.dart
-
17lib/widget/message/message_item.dart
-
82lib/widget/message/more_options_view.dart
-
547lib/widget/message/video_input_view.dart
-
291lib/widget/message/video_item.dart
-
450pubspec.lock
-
3pubspec.yaml
@ -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), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -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, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -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, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -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
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save