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; + } +}