You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

548 lines
20 KiB

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:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:wechat_camera_picker/wechat_camera_picker.dart';
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) {
SmartDialog.showToast('需要相机和麦克风权限才能录制视频');
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");
}
SmartDialog.showToast('❌ 选择视频失败: $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,
);
}
}