13 changed files with 1719 additions and 225 deletions
Split 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