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
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,
|
|
);
|
|
}
|
|
}
|
|
|