diff --git a/.idea/flutter_common.iml b/.idea/flutter_common.iml
index 462b161..b03b9f6 100644
--- a/.idea/flutter_common.iml
+++ b/.idea/flutter_common.iml
@@ -63,6 +63,12 @@
+
+
+
+
+
+
diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml
index 90107fd..7d17f3e 100644
--- a/.idea/libraries/Dart_Packages.xml
+++ b/.idea/libraries/Dart_Packages.xml
@@ -271,7 +271,7 @@
-
+
@@ -425,14 +425,14 @@
-
+
-
+
@@ -1013,7 +1013,7 @@
-
+
@@ -1505,10 +1505,10 @@
-
-
-
-
+
+
+
+
diff --git a/lib/flutter_common.dart b/lib/flutter_common.dart
index 7bfa5d6..787fe95 100644
--- a/lib/flutter_common.dart
+++ b/lib/flutter_common.dart
@@ -3,4 +3,5 @@ export 'calendarcalendar/custom_calendar_range_picker_widget.dart';
export 'calendarcalendar/custom_date_picker.dart';
export 'upload_image/look_images_widget.dart';
export 'upload_image/upload_images_tool.dart';
+export 'upload_video/video_batch_upload.dart';
export 'utils/date_utils.dart';
diff --git a/lib/upload_image/ossUtil.dart b/lib/upload_image/ossUtil.dart
index b3f1a73..26fa4e3 100755
--- a/lib/upload_image/ossUtil.dart
+++ b/lib/upload_image/ossUtil.dart
@@ -37,8 +37,15 @@ class UploadOss {
required String ossHost,
bool? isShowLoading,
}) async {
+ final String normalizedFileType =
+ fileType.toLowerCase() == 'jpeg' ? 'jpg' : fileType.toLowerCase();
+ final bool isVideo = normalizedFileType == 'mp4';
+ final String? imageContentType = isVideo
+ ? null
+ : (normalizedFileType == 'jpg' ? 'jpeg' : normalizedFileType);
// 生成oss的路径和文件名我这里目前设置的是moment/20201229/test.mp4
- String pathName = "$rootDir/${getDate()}/app-${getRandom(12)}.$fileType";
+ String pathName =
+ "$rootDir/${getDate()}/app-${getRandom(12)}.$normalizedFileType";
// 请求参数的form对象
FormData formdata = FormData.fromMap({
@@ -51,8 +58,8 @@ class UploadOss {
'success_action_status': '200',
'file': MultipartFile.fromFileSync(
path,
- contentType: fileType == 'mp4' ? null : DioMediaType("image", "jpg"),
- filename: "${getRandom(12)}.$fileType",
+ contentType: isVideo ? null : DioMediaType("image", imageContentType!),
+ filename: "${getRandom(12)}.$normalizedFileType",
),
});
if (isShowLoading == true) {
@@ -71,13 +78,19 @@ class UploadOss {
ossHost,
data: formdata,
options: Options(
- contentType: "multipart/form-data;image/jpg",
- headers: {'Content-Type': 'multipart/form-data;image/jpg'},
+ contentType: isVideo
+ ? "multipart/form-data"
+ : "multipart/form-data;image/$imageContentType",
+ headers: {
+ 'Content-Type': isVideo
+ ? "multipart/form-data"
+ : "multipart/form-data;image/$imageContentType",
+ },
),
);
print("response ===== $response");
EasyLoading.dismiss();
- if(fileType == 'jpg'){
+ if (!isVideo && path.contains('compressed_images_')) {
/// 删除临时文件
File(path).deleteSync();
}
diff --git a/lib/upload_image/upload_images_tool.dart b/lib/upload_image/upload_images_tool.dart
index ad669f8..66ce219 100644
--- a/lib/upload_image/upload_images_tool.dart
+++ b/lib/upload_image/upload_images_tool.dart
@@ -1,5 +1,4 @@
import 'dart:io';
-import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter_common/upload_image/ossUtil.dart';
@@ -12,6 +11,28 @@ import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
+class _CompressedImageFile {
+ const _CompressedImageFile({
+ required this.path,
+ required this.fileType,
+ });
+
+ final String path;
+ final String fileType;
+}
+
+class _ImageCompressionTarget {
+ const _ImageCompressionTarget({
+ required this.format,
+ required this.extension,
+ required this.fileType,
+ });
+
+ final CompressFormat format;
+ final String extension;
+ final String fileType;
+}
+
class UploadImagesTool {
static String _assetPathNameBuilder(AssetPathEntity path) {
final locale =
@@ -319,22 +340,13 @@ class UploadImagesTool {
);
/// 临时存储选中的图片
- final List selectedFiles = [];
+ final List<_CompressedImageFile> selectedFiles = [];
if (result != null && result.isNotEmpty) {
for (int i = 0; i < result.length; i++) {
final File? file = await result[i].file;
if (file != null) {
- /// 获取文件扩展名
- final String extension = file.absolute.path.split('.').last;
-
- /// 压缩并保存到临时文件
- final XFile? compressedFile =
- await FlutterImageCompress.compressAndGetFile(
- file.absolute.path,
- '${tempDir.path}/${DateTime.now().millisecondsSinceEpoch}_compressed.$extension',
- quality: 80,
- minWidth: 1920,
- minHeight: 1080);
+ final _CompressedImageFile? compressedFile =
+ await _compressGalleryImage(file, tempDir);
if (compressedFile != null) {
selectedFiles.add(compressedFile);
}
@@ -347,6 +359,7 @@ class UploadImagesTool {
for (var element in selectedFiles) {
String path = await saveNetworkImgGallery(
element.path,
+ fileType: element.fileType,
oSSAccessKeyId: oSSAccessKeyId ?? '',
ossHost: ossHost ?? '',
ossDirectory: ossDirectory ?? '',
@@ -361,6 +374,99 @@ class UploadImagesTool {
}
}
+ static Future<_CompressedImageFile?> _compressGalleryImage(
+ File file,
+ Directory tempDir,
+ ) async {
+ final _ImageCompressionTarget target =
+ _compressionTargetForPath(file.absolute.path);
+ final XFile? compressedFile = await _compressImageWithTarget(
+ file,
+ tempDir,
+ target,
+ );
+ if (compressedFile != null) {
+ return _CompressedImageFile(
+ path: compressedFile.path,
+ fileType: target.fileType,
+ );
+ }
+
+ if (target.format == CompressFormat.jpeg) {
+ return null;
+ }
+ const _ImageCompressionTarget fallbackTarget = _ImageCompressionTarget(
+ format: CompressFormat.jpeg,
+ extension: 'jpg',
+ fileType: 'jpg',
+ );
+ final XFile? fallbackFile = await _compressImageWithTarget(
+ file,
+ tempDir,
+ fallbackTarget,
+ );
+ if (fallbackFile == null) {
+ return null;
+ }
+ return _CompressedImageFile(
+ path: fallbackFile.path,
+ fileType: fallbackTarget.fileType,
+ );
+ }
+
+ static Future _compressImageWithTarget(
+ File file,
+ Directory tempDir,
+ _ImageCompressionTarget target,
+ ) async {
+ try {
+ return await FlutterImageCompress.compressAndGetFile(
+ file.absolute.path,
+ '${tempDir.path}/${DateTime.now().microsecondsSinceEpoch}_compressed.${target.extension}',
+ quality: 80,
+ minWidth: 1920,
+ minHeight: 1080,
+ format: target.format,
+ );
+ } catch (_) {
+ return null;
+ }
+ }
+
+ static _ImageCompressionTarget _compressionTargetForPath(String path) {
+ final String extension =
+ path.split('?').first.split('.').last.toLowerCase();
+ switch (extension) {
+ case 'png':
+ return const _ImageCompressionTarget(
+ format: CompressFormat.png,
+ extension: 'png',
+ fileType: 'png',
+ );
+ case 'webp':
+ return const _ImageCompressionTarget(
+ format: CompressFormat.webp,
+ extension: 'webp',
+ fileType: 'webp',
+ );
+ case 'heic':
+ case 'heif':
+ return const _ImageCompressionTarget(
+ format: CompressFormat.heic,
+ extension: 'heic',
+ fileType: 'heic',
+ );
+ case 'jpeg':
+ case 'jpg':
+ default:
+ return const _ImageCompressionTarget(
+ format: CompressFormat.jpeg,
+ extension: 'jpg',
+ fileType: 'jpg',
+ );
+ }
+ }
+
// 保存网络图片
static Future saveNetworkImg(
XFile file, {
diff --git a/lib/upload_video/video_batch_upload.dart b/lib/upload_video/video_batch_upload.dart
new file mode 100644
index 0000000..076aaf2
--- /dev/null
+++ b/lib/upload_video/video_batch_upload.dart
@@ -0,0 +1,4 @@
+export 'video_batch_upload_config.dart';
+export 'video_batch_upload_task.dart';
+export 'video_batch_uploader.dart';
+export 'video_batch_upload_widgets.dart';
diff --git a/lib/upload_video/video_batch_upload_config.dart b/lib/upload_video/video_batch_upload_config.dart
new file mode 100644
index 0000000..6c72547
--- /dev/null
+++ b/lib/upload_video/video_batch_upload_config.dart
@@ -0,0 +1,6 @@
+class VideoBatchUploadConfig {
+ const VideoBatchUploadConfig._();
+
+ static const int maxAssets = 9;
+ static const int maxConcurrent = 2;
+}
diff --git a/lib/upload_video/video_batch_upload_panel.dart b/lib/upload_video/video_batch_upload_panel.dart
new file mode 100644
index 0000000..ca7293d
--- /dev/null
+++ b/lib/upload_video/video_batch_upload_panel.dart
@@ -0,0 +1,347 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+
+import 'video_batch_upload_queue.dart';
+import 'video_batch_upload_task.dart';
+
+class VideoBatchUploadPanel extends StatelessWidget {
+ const VideoBatchUploadPanel({
+ super.key,
+ required this.queue,
+ });
+
+ final VideoBatchUploadQueue queue;
+
+ @override
+ Widget build(BuildContext context) {
+ if (!queue.hasTasks) {
+ return const SizedBox.shrink();
+ }
+ return Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: AnimatedSwitcher(
+ duration: const Duration(milliseconds: 180),
+ child: queue.isExpanded
+ ? _ExpandedUploadPanel(queue: queue)
+ : _CollapsedUploadBar(queue: queue),
+ ),
+ );
+ }
+}
+
+class _CollapsedUploadBar extends StatelessWidget {
+ const _CollapsedUploadBar({required this.queue});
+
+ final VideoBatchUploadQueue queue;
+
+ @override
+ Widget build(BuildContext context) {
+ return SafeArea(
+ top: false,
+ child: GestureDetector(
+ onTap: queue.toggleExpanded,
+ child: Container(
+ height: 70.h,
+ margin: EdgeInsets.fromLTRB(24.w, 0, 24.w, 18.h),
+ padding: EdgeInsets.symmetric(horizontal: 22.w),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(18.r),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.12),
+ blurRadius: 18.r,
+ offset: Offset(0, 6.h),
+ ),
+ ],
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.cloud_upload_outlined,
+ color: const Color(0xFF46A5FF),
+ size: 34.sp,
+ ),
+ SizedBox(width: 12.w),
+ Expanded(
+ child: Text(
+ '正在上传${queue.assetType.label} ${queue.finishedCount}/${queue.totalCount}',
+ style: TextStyle(
+ fontSize: 24.sp,
+ color: const Color(0xFF1F2937),
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ Icon(
+ Icons.keyboard_arrow_up_rounded,
+ color: const Color(0xFF667085),
+ size: 34.sp,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ExpandedUploadPanel extends StatelessWidget {
+ const _ExpandedUploadPanel({required this.queue});
+
+ final VideoBatchUploadQueue queue;
+
+ @override
+ Widget build(BuildContext context) {
+ final double bottomPadding = MediaQuery.of(context).padding.bottom;
+ return Container(
+ constraints: BoxConstraints(maxHeight: 560.h),
+ padding: EdgeInsets.fromLTRB(24.w, 20.h, 24.w, 18.h + bottomPadding),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.12),
+ blurRadius: 18.r,
+ offset: Offset(0, -6.h),
+ ),
+ ],
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ '上传${queue.assetType.label}',
+ style: TextStyle(
+ fontSize: 30.sp,
+ color: const Color(0xFF111827),
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ Text(
+ '${queue.finishedCount}/${queue.totalCount}',
+ style: TextStyle(
+ fontSize: 24.sp,
+ color: const Color(0xFF667085),
+ ),
+ ),
+ SizedBox(width: 10.w),
+ GestureDetector(
+ onTap: queue.toggleExpanded,
+ behavior: HitTestBehavior.opaque,
+ child: Icon(
+ Icons.keyboard_arrow_down_rounded,
+ color: const Color(0xFF667085),
+ size: 38.sp,
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: 16.h),
+ Flexible(
+ child: ListView.separated(
+ padding: EdgeInsets.zero,
+ shrinkWrap: true,
+ itemCount: queue.tasks.length,
+ separatorBuilder: (_, __) => SizedBox(height: 12.h),
+ itemBuilder: (_, int index) {
+ final VideoBatchUploadTask task = queue.tasks[index];
+ return _UploadTaskCell(
+ task: task,
+ onRetry: () => queue.retryTask(task),
+ onRemove: () => queue.removeTask(task),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _UploadTaskCell extends StatelessWidget {
+ const _UploadTaskCell({
+ required this.task,
+ required this.onRetry,
+ required this.onRemove,
+ });
+
+ final VideoBatchUploadTask task;
+ final VoidCallback onRetry;
+ final VoidCallback onRemove;
+
+ @override
+ Widget build(BuildContext context) {
+ final Color statusColor = task.status == VideoBatchUploadStatus.failed
+ ? const Color(0xFFE5484D)
+ : const Color(0xFF667085);
+ return Container(
+ padding: EdgeInsets.all(12.w),
+ decoration: BoxDecoration(
+ color: const Color(0xFFF7F8FA),
+ borderRadius: BorderRadius.circular(16.r),
+ ),
+ child: Row(
+ children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(12.r),
+ child: SizedBox(
+ width: 86.w,
+ height: 86.w,
+ child: task.thumbnail == null
+ ? Container(
+ color: const Color(0xFFEDEFF3),
+ alignment: Alignment.center,
+ child: Icon(
+ task.assetType == VideoBatchUploadAssetType.video
+ ? Icons.videocam_outlined
+ : Icons.image_outlined,
+ size: 46.sp,
+ color: const Color(0xFF98A2B3),
+ ),
+ )
+ : Image.memory(
+ task.thumbnail!,
+ fit: BoxFit.cover,
+ ),
+ ),
+ ),
+ SizedBox(width: 14.w),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ task.fileName,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 24.sp,
+ color: const Color(0xFF1F2937),
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ SizedBox(height: 8.h),
+ Text(
+ _subtitle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 20.sp,
+ color: const Color(0xFF8D90A0),
+ ),
+ ),
+ SizedBox(height: 10.h),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(999),
+ child: LinearProgressIndicator(
+ value: task.progress.clamp(0, 1),
+ minHeight: 6.h,
+ backgroundColor: const Color(0xFFE5E7EB),
+ valueColor: AlwaysStoppedAnimation(
+ task.status == VideoBatchUploadStatus.failed
+ ? const Color(0xFFE5484D)
+ : const Color(0xFF46A5FF),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(width: 12.w),
+ SizedBox(
+ width: 96.w,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ task.status == VideoBatchUploadStatus.failed &&
+ task.errorMessage.isNotEmpty
+ ? task.errorMessage
+ : task.status.label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.right,
+ style: TextStyle(
+ fontSize: 20.sp,
+ color: statusColor,
+ ),
+ ),
+ SizedBox(height: 12.h),
+ _TaskActionButton(
+ task: task,
+ onRetry: onRetry,
+ onRemove: onRemove,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ String get _subtitle {
+ final List parts = [
+ if (task.durationText.isNotEmpty) task.durationText,
+ if (task.fileSizeText.isNotEmpty) task.fileSizeText,
+ ];
+ return parts.isEmpty ? task.assetType.localLabel : parts.join(' · ');
+ }
+}
+
+class _TaskActionButton extends StatelessWidget {
+ const _TaskActionButton({
+ required this.task,
+ required this.onRetry,
+ required this.onRemove,
+ });
+
+ final VideoBatchUploadTask task;
+ final VoidCallback onRetry;
+ final VoidCallback onRemove;
+
+ @override
+ Widget build(BuildContext context) {
+ if (task.canRetry) {
+ return GestureDetector(
+ onTap: onRetry,
+ behavior: HitTestBehavior.opaque,
+ child: Text(
+ '重试',
+ style: TextStyle(
+ fontSize: 22.sp,
+ color: const Color(0xFF46A5FF),
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ );
+ }
+ if (task.status == VideoBatchUploadStatus.completed) {
+ return Icon(
+ Icons.check_circle_rounded,
+ size: 30.sp,
+ color: const Color(0xFF12B76A),
+ );
+ }
+ return GestureDetector(
+ onTap: onRemove,
+ behavior: HitTestBehavior.opaque,
+ child: Icon(
+ task.status == VideoBatchUploadStatus.uploading
+ ? Icons.close_rounded
+ : Icons.delete_outline_rounded,
+ size: 30.sp,
+ color: const Color(0xFF98A2B3),
+ ),
+ );
+ }
+}
diff --git a/lib/upload_video/video_batch_upload_queue.dart b/lib/upload_video/video_batch_upload_queue.dart
new file mode 100644
index 0000000..74b1d2b
--- /dev/null
+++ b/lib/upload_video/video_batch_upload_queue.dart
@@ -0,0 +1,405 @@
+import 'dart:async';
+
+import 'package:dio/dio.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_common/utils/toast_utils.dart';
+
+import 'video_batch_upload_config.dart';
+import 'video_batch_upload_service.dart';
+import 'video_batch_upload_task.dart';
+
+class VideoBatchUploadQueue {
+ VideoBatchUploadQueue({
+ required this.contextProvider,
+ required this.uploadTask,
+ this.saveTask,
+ this.batchSaveTask,
+ required this.onChanged,
+ this.assetType = VideoBatchUploadAssetType.video,
+ this.onCompleted,
+ this.maxAssets = VideoBatchUploadConfig.maxAssets,
+ this.maxConcurrent = VideoBatchUploadConfig.maxConcurrent,
+ });
+
+ final BuildContext? Function() contextProvider;
+ final VideoBatchUploadUploadTask uploadTask;
+ final VideoBatchUploadSaveTask? saveTask;
+ final VideoBatchUploadBatchSaveTask? batchSaveTask;
+ final VoidCallback onChanged;
+ final VideoBatchUploadAssetType assetType;
+ final FutureOr Function()? onCompleted;
+ final int maxAssets;
+ final int maxConcurrent;
+
+ final List tasks = [];
+ int _batchTotalCount = 0;
+ int _completedRemovedCount = 0;
+ bool isExpanded = false;
+ bool _isRunning = false;
+ bool _completionNotified = false;
+ bool _isDisposed = false;
+
+ bool get hasTasks => tasks.isNotEmpty;
+
+ bool get hasActiveUploads => tasks.any((VideoBatchUploadTask task) {
+ return task.isActive;
+ });
+
+ int get totalCount => _batchTotalCount > 0 ? _batchTotalCount : tasks.length;
+
+ int get completedCount => tasks
+ .where((VideoBatchUploadTask task) =>
+ task.status == VideoBatchUploadStatus.completed)
+ .length;
+
+ int get failedCount => tasks
+ .where((VideoBatchUploadTask task) =>
+ task.status == VideoBatchUploadStatus.failed)
+ .length;
+
+ int get finishedCount =>
+ _completedRemovedCount + completedCount + failedCount;
+
+ bool get _usesBatchSave => batchSaveTask != null;
+
+ Future pickAndStart() async {
+ if (hasActiveUploads) {
+ isExpanded = true;
+ _notifyChanged();
+ ToastUtils.showToast(msg: '${assetType.label}正在上传中');
+ return;
+ }
+
+ final BuildContext? context = contextProvider();
+ if (context == null || !context.mounted) {
+ return;
+ }
+
+ final List pickedTasks =
+ await VideoBatchUploadPicker.pickMedia(
+ context,
+ maxAssets: maxAssets,
+ assetType: assetType,
+ );
+ if (pickedTasks.isEmpty) {
+ return;
+ }
+
+ tasks
+ ..clear()
+ ..addAll(pickedTasks);
+ _batchTotalCount = pickedTasks.length;
+ _completedRemovedCount = 0;
+ isExpanded = true;
+ _completionNotified = false;
+ _notifyChanged();
+ unawaited(_runQueue());
+ }
+
+ void toggleExpanded() {
+ if (!hasTasks) {
+ return;
+ }
+ isExpanded = !isExpanded;
+ _notifyChanged();
+ }
+
+ void retryTask(VideoBatchUploadTask task) {
+ if (!tasks.contains(task) || !task.canRetry) {
+ return;
+ }
+ task
+ ..status = VideoBatchUploadStatus.waiting
+ ..progress = 0
+ ..uploadUrl = ''
+ ..errorMessage = '';
+ _completionNotified = false;
+ isExpanded = true;
+ _notifyChanged();
+ unawaited(_runQueue());
+ }
+
+ void removeTask(VideoBatchUploadTask task) {
+ if (!tasks.contains(task)) {
+ return;
+ }
+ task.cancelToken?.cancel('removed');
+ tasks.remove(task);
+ if (tasks.isEmpty) {
+ isExpanded = false;
+ _resetBatchProgress();
+ }
+ _notifyChanged();
+ }
+
+ void clear() {
+ for (final VideoBatchUploadTask task in tasks) {
+ task.cancelToken?.cancel('cleared');
+ }
+ tasks.clear();
+ isExpanded = false;
+ _isRunning = false;
+ _resetBatchProgress();
+ _notifyChanged();
+ }
+
+ void cancelAll() {
+ for (final VideoBatchUploadTask task in tasks) {
+ task.cancelToken?.cancel('cancelled');
+ }
+ for (final VideoBatchUploadTask task in tasks) {
+ if (task.isActive) {
+ task
+ ..status = VideoBatchUploadStatus.failed
+ ..errorMessage = '已取消';
+ }
+ }
+ _isRunning = false;
+ _notifyChanged();
+ }
+
+ void requestExit({required VoidCallback onExit}) {
+ if (!hasActiveUploads) {
+ onExit();
+ return;
+ }
+ ToastUtils.showAlterDialog(
+ titleText: '${assetType.label}还在上传',
+ contentText: '离开会中断本次上传,确定要离开吗?',
+ confirmText: '离开',
+ confirmCallback: () {
+ cancelAll();
+ onExit();
+ },
+ );
+ }
+
+ void dispose() {
+ _isDisposed = true;
+ for (final VideoBatchUploadTask task in tasks) {
+ task.cancelToken?.cancel('disposed');
+ }
+ }
+
+ void _resetBatchProgress() {
+ _batchTotalCount = 0;
+ _completedRemovedCount = 0;
+ _completionNotified = false;
+ }
+
+ Future _runQueue() async {
+ if (_isRunning || _isDisposed) {
+ return;
+ }
+ _isRunning = true;
+ final int workerCount = maxConcurrent < 1 ? 1 : maxConcurrent;
+ final List> workers = List>.generate(
+ workerCount,
+ (_) => _uploadWorker(),
+ );
+ await Future.wait(workers);
+ _isRunning = false;
+ await _saveUploadedBatchIfNeeded();
+ await _notifyCompletedIfNeeded();
+ }
+
+ Future _uploadWorker() async {
+ while (true) {
+ if (_isDisposed) {
+ return;
+ }
+ final VideoBatchUploadTask? task = _nextWaitingTask();
+ if (task == null) {
+ return;
+ }
+ await _processTask(task);
+ }
+ }
+
+ VideoBatchUploadTask? _nextWaitingTask() {
+ for (final VideoBatchUploadTask task in tasks) {
+ if (task.status == VideoBatchUploadStatus.waiting) {
+ return task;
+ }
+ }
+ return null;
+ }
+
+ Future _processTask(VideoBatchUploadTask task) async {
+ task
+ ..status = VideoBatchUploadStatus.uploading
+ ..progress = 0
+ ..errorMessage = ''
+ ..cancelToken = CancelToken();
+ _notifyChanged();
+
+ try {
+ final String uploadUrl = await uploadTask(
+ task,
+ (double progress) {
+ if (!tasks.contains(task)) {
+ return;
+ }
+ task.progress = progress.clamp(0, 1);
+ _notifyChanged();
+ },
+ );
+ if (!tasks.contains(task)) {
+ return;
+ }
+ if (uploadUrl.trim().isEmpty) {
+ throw Exception('empty upload url');
+ }
+ task
+ ..uploadUrl = uploadUrl
+ ..progress = 1
+ ..status = VideoBatchUploadStatus.uploaded;
+ _notifyChanged();
+
+ if (_usesBatchSave) {
+ return;
+ }
+
+ task.status = VideoBatchUploadStatus.saving;
+ _notifyChanged();
+ final bool saved = await saveTask!(task);
+ if (!tasks.contains(task)) {
+ return;
+ }
+ if (saved) {
+ task.status = VideoBatchUploadStatus.completed;
+ _completedRemovedCount += 1;
+ tasks.remove(task);
+ if (tasks.isEmpty) {
+ isExpanded = false;
+ }
+ } else {
+ task
+ ..status = VideoBatchUploadStatus.failed
+ ..errorMessage = '入库失败';
+ }
+ } on DioException catch (error) {
+ if (!tasks.contains(task)) {
+ return;
+ }
+ task
+ ..status = VideoBatchUploadStatus.failed
+ ..errorMessage =
+ CancelToken.isCancel(error) ? '已取消' : _networkErrorText(error);
+ } catch (_) {
+ if (!tasks.contains(task)) {
+ return;
+ }
+ task
+ ..status = VideoBatchUploadStatus.failed
+ ..errorMessage = '上传失败';
+ } finally {
+ task.cancelToken = null;
+ _notifyChanged();
+ }
+ }
+
+ Future _saveUploadedBatchIfNeeded() async {
+ if (!_usesBatchSave || _isDisposed) {
+ return;
+ }
+ final List successfulUploadTasks = tasks
+ .where((VideoBatchUploadTask task) =>
+ task.status == VideoBatchUploadStatus.uploaded)
+ .toList();
+ if (successfulUploadTasks.isEmpty) {
+ return;
+ }
+
+ for (final VideoBatchUploadTask task in successfulUploadTasks) {
+ task.status = VideoBatchUploadStatus.saving;
+ }
+ _notifyChanged();
+
+ try {
+ final bool saved = await batchSaveTask!(List.of(
+ successfulUploadTasks,
+ growable: false,
+ ));
+ if (_isDisposed) {
+ return;
+ }
+ if (saved) {
+ _completedRemovedCount += successfulUploadTasks.length;
+ tasks.removeWhere(successfulUploadTasks.contains);
+ if (tasks.isEmpty) {
+ isExpanded = false;
+ }
+ return;
+ }
+ for (final VideoBatchUploadTask task in successfulUploadTasks) {
+ if (tasks.contains(task)) {
+ task
+ ..status = VideoBatchUploadStatus.failed
+ ..errorMessage = '入库失败';
+ }
+ }
+ } catch (_) {
+ if (_isDisposed) {
+ return;
+ }
+ for (final VideoBatchUploadTask task in successfulUploadTasks) {
+ if (tasks.contains(task)) {
+ task
+ ..status = VideoBatchUploadStatus.failed
+ ..errorMessage = '入库失败';
+ }
+ }
+ } finally {
+ _notifyChanged();
+ }
+ }
+
+ String _networkErrorText(DioException error) {
+ switch (error.type) {
+ case DioExceptionType.connectionTimeout:
+ case DioExceptionType.sendTimeout:
+ case DioExceptionType.receiveTimeout:
+ case DioExceptionType.connectionError:
+ return '网络异常';
+ case DioExceptionType.cancel:
+ return '已取消';
+ case DioExceptionType.badResponse:
+ return '上传失败';
+ case DioExceptionType.badCertificate:
+ case DioExceptionType.unknown:
+ return '上传失败';
+ }
+ }
+
+ Future _notifyCompletedIfNeeded() async {
+ if (_isDisposed ||
+ _completionNotified ||
+ totalCount == 0 ||
+ hasActiveUploads) {
+ return;
+ }
+ _completionNotified = true;
+ await onCompleted?.call();
+ if (failedCount > 0) {
+ ToastUtils.showToast(
+ msg:
+ '已上传 $_completedRemovedCount ${assetType.unit},失败 $failedCount ${assetType.unit}');
+ return;
+ }
+ final int completed = _completedRemovedCount;
+ ToastUtils.showToast(
+ msg: '已上传 $completed ${assetType.unit}${assetType.label}');
+ tasks.clear();
+ isExpanded = false;
+ _resetBatchProgress();
+ _notifyChanged();
+ }
+
+ void _notifyChanged() {
+ if (_isDisposed) {
+ return;
+ }
+ onChanged();
+ }
+}
diff --git a/lib/upload_video/video_batch_upload_service.dart b/lib/upload_video/video_batch_upload_service.dart
new file mode 100644
index 0000000..2d0cf39
--- /dev/null
+++ b/lib/upload_video/video_batch_upload_service.dart
@@ -0,0 +1,100 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:flutter/widgets.dart';
+import 'package:wechat_assets_picker/wechat_assets_picker.dart';
+
+import 'video_batch_upload_config.dart';
+import 'video_batch_upload_task.dart';
+
+class VideoBatchUploadPicker {
+ const VideoBatchUploadPicker._();
+
+ static Future> pickVideos(
+ BuildContext context, {
+ int maxAssets = VideoBatchUploadConfig.maxAssets,
+ }) async {
+ return pickMedia(
+ context,
+ maxAssets: maxAssets,
+ assetType: VideoBatchUploadAssetType.video,
+ );
+ }
+
+ static Future> pickImages(
+ BuildContext context, {
+ int maxAssets = VideoBatchUploadConfig.maxAssets,
+ }) async {
+ return pickMedia(
+ context,
+ maxAssets: maxAssets,
+ assetType: VideoBatchUploadAssetType.image,
+ );
+ }
+
+ static Future> pickMedia(
+ BuildContext context, {
+ int maxAssets = VideoBatchUploadConfig.maxAssets,
+ VideoBatchUploadAssetType assetType = VideoBatchUploadAssetType.video,
+ }) async {
+ final List? assets = await AssetPicker.pickAssets(
+ context,
+ pickerConfig: AssetPickerConfig(
+ maxAssets: maxAssets,
+ requestType: _requestType(assetType),
+ ),
+ );
+ if (assets == null || assets.isEmpty) {
+ return [];
+ }
+
+ final List tasks = [];
+ for (final AssetEntity asset in assets) {
+ final File? file = await asset.file;
+ if (file == null) {
+ continue;
+ }
+ final Uint8List? thumbnail = await asset.thumbnailDataWithSize(
+ const ThumbnailSize.square(220),
+ quality: 72,
+ );
+ final int fileSize = await file.length();
+ tasks.add(
+ VideoBatchUploadTask(
+ id: '${asset.id}_${DateTime.now().microsecondsSinceEpoch}',
+ assetType: assetType,
+ file: file,
+ fileName: _normalizeFileName(asset.title, file.path, assetType),
+ fileSize: fileSize,
+ duration:
+ assetType == VideoBatchUploadAssetType.video ? asset.duration : 0,
+ thumbnail: thumbnail,
+ ),
+ );
+ }
+ return tasks;
+ }
+
+ static RequestType _requestType(VideoBatchUploadAssetType assetType) {
+ switch (assetType) {
+ case VideoBatchUploadAssetType.image:
+ return RequestType.image;
+ case VideoBatchUploadAssetType.video:
+ return RequestType.video;
+ }
+ }
+
+ static String _normalizeFileName(
+ String? assetTitle,
+ String path,
+ VideoBatchUploadAssetType assetType,
+ ) {
+ final String title = (assetTitle ?? '').trim();
+ if (title.isNotEmpty) {
+ return title;
+ }
+ final List segments = path.split(Platform.pathSeparator);
+ final String fileName = segments.isEmpty ? '' : segments.last.trim();
+ return fileName.isEmpty ? assetType.defaultFileName : fileName;
+ }
+}
diff --git a/lib/upload_video/video_batch_upload_task.dart b/lib/upload_video/video_batch_upload_task.dart
new file mode 100644
index 0000000..35267ef
--- /dev/null
+++ b/lib/upload_video/video_batch_upload_task.dart
@@ -0,0 +1,176 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:dio/dio.dart';
+
+typedef VideoBatchUploadProgressCallback = void Function(double progress);
+
+typedef VideoBatchUploadUploadTask = Future Function(
+ VideoBatchUploadTask task,
+ VideoBatchUploadProgressCallback onProgress,
+);
+
+typedef VideoBatchUploadSaveTask = Future Function(
+ VideoBatchUploadTask task,
+);
+
+typedef VideoBatchUploadBatchSaveTask = Future Function(
+ List tasks,
+);
+
+enum VideoBatchUploadAssetType {
+ image,
+ video,
+}
+
+extension VideoBatchUploadAssetTypeText on VideoBatchUploadAssetType {
+ String get label {
+ switch (this) {
+ case VideoBatchUploadAssetType.image:
+ return '图片';
+ case VideoBatchUploadAssetType.video:
+ return '视频';
+ }
+ }
+
+ String get unit {
+ switch (this) {
+ case VideoBatchUploadAssetType.image:
+ return '张';
+ case VideoBatchUploadAssetType.video:
+ return '个';
+ }
+ }
+
+ String get localLabel {
+ switch (this) {
+ case VideoBatchUploadAssetType.image:
+ return '本地图片';
+ case VideoBatchUploadAssetType.video:
+ return '本地视频';
+ }
+ }
+
+ String get defaultFileName {
+ switch (this) {
+ case VideoBatchUploadAssetType.image:
+ return 'image.jpg';
+ case VideoBatchUploadAssetType.video:
+ return 'video.mp4';
+ }
+ }
+}
+
+enum VideoBatchUploadStatus {
+ waiting,
+ uploading,
+ uploaded,
+ saving,
+ completed,
+ failed,
+}
+
+extension VideoBatchUploadStatusText on VideoBatchUploadStatus {
+ String get label {
+ switch (this) {
+ case VideoBatchUploadStatus.waiting:
+ return '等待中';
+ case VideoBatchUploadStatus.uploading:
+ return '上传中';
+ case VideoBatchUploadStatus.uploaded:
+ return '上传成功';
+ case VideoBatchUploadStatus.saving:
+ return '入库中';
+ case VideoBatchUploadStatus.completed:
+ return '已完成';
+ case VideoBatchUploadStatus.failed:
+ return '失败';
+ }
+ }
+}
+
+class VideoBatchUploadTask {
+ VideoBatchUploadTask({
+ required this.id,
+ this.assetType = VideoBatchUploadAssetType.video,
+ required this.file,
+ required this.fileName,
+ required this.fileSize,
+ required this.duration,
+ this.thumbnail,
+ });
+
+ final String id;
+ final VideoBatchUploadAssetType assetType;
+ final File file;
+ final String fileName;
+ final int fileSize;
+ final int duration;
+ final Uint8List? thumbnail;
+
+ VideoBatchUploadStatus status = VideoBatchUploadStatus.waiting;
+ double progress = 0;
+ String uploadUrl = '';
+ String errorMessage = '';
+ CancelToken? cancelToken;
+
+ String get ossUrl => uploadUrl;
+
+ set ossUrl(String value) {
+ uploadUrl = value;
+ }
+
+ bool get isActive =>
+ status == VideoBatchUploadStatus.waiting ||
+ status == VideoBatchUploadStatus.uploading ||
+ status == VideoBatchUploadStatus.uploaded ||
+ status == VideoBatchUploadStatus.saving;
+
+ bool get canRetry => status == VideoBatchUploadStatus.failed;
+
+ bool get canRemove =>
+ status == VideoBatchUploadStatus.waiting ||
+ status == VideoBatchUploadStatus.completed ||
+ status == VideoBatchUploadStatus.failed;
+
+ String get fileSizeText {
+ if (fileSize <= 0) {
+ return '';
+ }
+ final double mb = fileSize / 1024 / 1024;
+ if (mb >= 1) {
+ return '${mb.toStringAsFixed(1)}MB';
+ }
+ final double kb = fileSize / 1024;
+ return '${kb.toStringAsFixed(0)}KB';
+ }
+
+ String get durationText {
+ if (duration <= 0) {
+ return '';
+ }
+ final int minutes = duration ~/ 60;
+ final int seconds = duration % 60;
+ return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
+ }
+
+ Map toVideoPayload({
+ String urlKey = 'fileURL',
+ String fileNameKey = 'fileName',
+ String durationKey = 'duration',
+ }) {
+ return {
+ urlKey: uploadUrl,
+ fileNameKey: fileName,
+ if (duration > 0) durationKey: duration,
+ };
+ }
+
+ Map toLibraryVideoPayload() {
+ return toVideoPayload();
+ }
+
+ String toLibraryImageURL() {
+ return uploadUrl;
+ }
+}
diff --git a/lib/upload_video/video_batch_upload_widgets.dart b/lib/upload_video/video_batch_upload_widgets.dart
new file mode 100644
index 0000000..e678db3
--- /dev/null
+++ b/lib/upload_video/video_batch_upload_widgets.dart
@@ -0,0 +1,81 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+
+import 'video_batch_upload_task.dart';
+
+class VideoBatchUploadBackButton extends StatelessWidget {
+ const VideoBatchUploadBackButton({
+ super.key,
+ required this.onTap,
+ this.iconSize = 19,
+ });
+
+ final VoidCallback onTap;
+ final double iconSize;
+
+ @override
+ Widget build(BuildContext context) {
+ return IconButton(
+ onPressed: onTap,
+ icon: Icon(
+ Icons.arrow_back_ios_new,
+ color: Colors.black,
+ size: iconSize,
+ ),
+ );
+ }
+}
+
+class VideoBatchUploadIconButton extends StatelessWidget {
+ const VideoBatchUploadIconButton({
+ super.key,
+ required this.onTap,
+ this.assetType = VideoBatchUploadAssetType.video,
+ this.iconSize,
+ this.rightPadding,
+ });
+
+ final VoidCallback onTap;
+ final VideoBatchUploadAssetType assetType;
+ final double? iconSize;
+ final double? rightPadding;
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Padding(
+ padding: EdgeInsets.only(right: rightPadding ?? 30.w),
+ child: Icon(
+ assetType == VideoBatchUploadAssetType.video
+ ? Icons.video_call_outlined
+ : Icons.add_photo_alternate_outlined,
+ size: iconSize ?? 50.sp,
+ color: const Color(0xFF1D1D21),
+ ),
+ ),
+ );
+ }
+}
+
+class VideoBatchUploadEmptyPlaceholder extends StatelessWidget {
+ const VideoBatchUploadEmptyPlaceholder({
+ super.key,
+ this.title = '这个视频库里还没有视频',
+ });
+
+ final String title;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Text(
+ title,
+ style: TextStyle(
+ fontSize: 28.sp,
+ color: const Color(0xFF8D90A0),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/upload_video/video_batch_uploader.dart b/lib/upload_video/video_batch_uploader.dart
new file mode 100644
index 0000000..d7c5c5c
--- /dev/null
+++ b/lib/upload_video/video_batch_uploader.dart
@@ -0,0 +1,100 @@
+import 'dart:async';
+
+import 'package:flutter/widgets.dart';
+
+import 'video_batch_upload_config.dart';
+import 'video_batch_upload_panel.dart';
+import 'video_batch_upload_queue.dart';
+import 'video_batch_upload_task.dart';
+
+typedef VideoBatchUploadCallback = FutureOr Function();
+
+class VideoBatchUploader {
+ VideoBatchUploader({
+ required BuildContext? Function() contextProvider,
+ required VideoBatchUploadUploadTask uploadTask,
+ VideoBatchUploadSaveTask? saveTask,
+ VideoBatchUploadBatchSaveTask? batchSaveTask,
+ VideoBatchUploadAssetType assetType = VideoBatchUploadAssetType.video,
+ VoidCallback? onChanged,
+ VideoBatchUploadCallback? onCompleted,
+ int? maxAssets,
+ int? maxConcurrent,
+ }) : assert(saveTask != null || batchSaveTask != null),
+ _contextProvider = contextProvider,
+ _onChanged = onChanged {
+ queue = VideoBatchUploadQueue(
+ contextProvider: contextProvider,
+ uploadTask: uploadTask,
+ saveTask: saveTask,
+ batchSaveTask: batchSaveTask,
+ onChanged: _handleChanged,
+ assetType: assetType,
+ onCompleted: onCompleted,
+ maxAssets: maxAssets ?? VideoBatchUploadConfig.maxAssets,
+ maxConcurrent: maxConcurrent ?? VideoBatchUploadConfig.maxConcurrent,
+ );
+ }
+
+ final BuildContext? Function() _contextProvider;
+ final VoidCallback? _onChanged;
+ late final VideoBatchUploadQueue queue;
+ OverlayEntry? _overlayEntry;
+
+ bool get hasActiveUploads => queue.hasActiveUploads;
+
+ Future start() async {
+ await queue.pickAndStart();
+ }
+
+ void requestExit({required VoidCallback onExit}) {
+ queue.requestExit(onExit: onExit);
+ }
+
+ void clear() {
+ queue.clear();
+ _syncOverlay();
+ }
+
+ void dispose() {
+ queue.dispose();
+ _removeOverlay();
+ }
+
+ void _handleChanged() {
+ _syncOverlay();
+ _overlayEntry?.markNeedsBuild();
+ _onChanged?.call();
+ }
+
+ void _syncOverlay() {
+ if (queue.hasTasks) {
+ _insertOverlayIfNeeded();
+ return;
+ }
+ _removeOverlay();
+ }
+
+ void _insertOverlayIfNeeded() {
+ if (_overlayEntry != null) {
+ return;
+ }
+ final BuildContext? context = _contextProvider();
+ if (context == null || !context.mounted) {
+ return;
+ }
+ final OverlayState? overlay = Overlay.maybeOf(context);
+ if (overlay == null) {
+ return;
+ }
+ _overlayEntry = OverlayEntry(
+ builder: (_) => VideoBatchUploadPanel(queue: queue),
+ );
+ overlay.insert(_overlayEntry!);
+ }
+
+ void _removeOverlay() {
+ _overlayEntry?.remove();
+ _overlayEntry = null;
+ }
+}