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_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..3304458 --- /dev/null +++ b/lib/upload_video/video_batch_upload_panel.dart @@ -0,0 +1,345 @@ +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.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( + '上传视频', + 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( + Icons.videocam_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 ? '本地视频' : 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..1cb2983 --- /dev/null +++ b/lib/upload_video/video_batch_upload_queue.dart @@ -0,0 +1,335 @@ +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, + required this.saveTask, + required this.onChanged, + this.onCompleted, + this.maxAssets = VideoBatchUploadConfig.maxAssets, + this.maxConcurrent = VideoBatchUploadConfig.maxConcurrent, + }); + + final BuildContext? Function() contextProvider; + final VideoBatchUploadUploadTask uploadTask; + final VideoBatchUploadSaveTask saveTask; + final VoidCallback onChanged; + 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; + + Future pickAndStart() async { + if (hasActiveUploads) { + isExpanded = true; + _notifyChanged(); + ToastUtils.showToast(msg: '视频正在上传中'); + return; + } + + final BuildContext? context = contextProvider(); + if (context == null || !context.mounted) { + return; + } + + final List pickedTasks = + await VideoBatchUploadPicker.pickVideos( + context, + maxAssets: maxAssets, + ); + 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: '视频还在上传', + 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 _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(); + + 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(); + } + } + + 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 个,失败 $failedCount 个'); + return; + } + final int completed = _completedRemovedCount; + ToastUtils.showToast(msg: '已上传 $completed 个视频'); + 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..6c2aa4c --- /dev/null +++ b/lib/upload_video/video_batch_upload_service.dart @@ -0,0 +1,62 @@ +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 { + final List? assets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + maxAssets: maxAssets, + requestType: RequestType.video, + ), + ); + 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}', + file: file, + fileName: _normalizeFileName(asset.title, file.path), + fileSize: fileSize, + duration: asset.duration, + thumbnail: thumbnail, + ), + ); + } + return tasks; + } + + static String _normalizeFileName(String? assetTitle, String path) { + 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 ? 'video.mp4' : 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..9e6357b --- /dev/null +++ b/lib/upload_video/video_batch_upload_task.dart @@ -0,0 +1,123 @@ +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, +); + +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, + required this.file, + required this.fileName, + required this.fileSize, + required this.duration, + this.thumbnail, + }); + + final String id; + 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(); + } +} 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..0d40476 --- /dev/null +++ b/lib/upload_video/video_batch_upload_widgets.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.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.iconSize, + this.rightPadding, + }); + + final VoidCallback onTap; + 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( + Icons.video_call_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..cdc16f9 --- /dev/null +++ b/lib/upload_video/video_batch_uploader.dart @@ -0,0 +1,95 @@ +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, + required VideoBatchUploadSaveTask saveTask, + VoidCallback? onChanged, + VideoBatchUploadCallback? onCompleted, + int? maxAssets, + int? maxConcurrent, + }) : _contextProvider = contextProvider, + _onChanged = onChanged { + queue = VideoBatchUploadQueue( + contextProvider: contextProvider, + uploadTask: uploadTask, + saveTask: saveTask, + onChanged: _handleChanged, + 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; + } +}