feat: add video batch upload queue
This commit is contained in:
@@ -3,4 +3,5 @@ export 'calendarcalendar/custom_calendar_range_picker_widget.dart';
|
|||||||
export 'calendarcalendar/custom_date_picker.dart';
|
export 'calendarcalendar/custom_date_picker.dart';
|
||||||
export 'upload_image/look_images_widget.dart';
|
export 'upload_image/look_images_widget.dart';
|
||||||
export 'upload_image/upload_images_tool.dart';
|
export 'upload_image/upload_images_tool.dart';
|
||||||
|
export 'upload_video/video_batch_upload.dart';
|
||||||
export 'utils/date_utils.dart';
|
export 'utils/date_utils.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';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class VideoBatchUploadConfig {
|
||||||
|
const VideoBatchUploadConfig._();
|
||||||
|
|
||||||
|
static const int maxAssets = 9;
|
||||||
|
static const int maxConcurrent = 2;
|
||||||
|
}
|
||||||
@@ -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>[
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.12),
|
||||||
|
blurRadius: 18.r,
|
||||||
|
offset: Offset(0, 6.h),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
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>[
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.12),
|
||||||
|
blurRadius: 18.r,
|
||||||
|
offset: Offset(0, -6.h),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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<Color>(
|
||||||
|
task.status == VideoBatchUploadStatus.failed
|
||||||
|
? const Color(0xFFE5484D)
|
||||||
|
: const Color(0xFF46A5FF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
SizedBox(
|
||||||
|
width: 96.w,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: <Widget>[
|
||||||
|
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<String> parts = <String>[
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> Function()? onCompleted;
|
||||||
|
final int maxAssets;
|
||||||
|
final int maxConcurrent;
|
||||||
|
|
||||||
|
final List<VideoBatchUploadTask> tasks = <VideoBatchUploadTask>[];
|
||||||
|
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<void> pickAndStart() async {
|
||||||
|
if (hasActiveUploads) {
|
||||||
|
isExpanded = true;
|
||||||
|
_notifyChanged();
|
||||||
|
ToastUtils.showToast(msg: '视频正在上传中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final BuildContext? context = contextProvider();
|
||||||
|
if (context == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<VideoBatchUploadTask> 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<void> _runQueue() async {
|
||||||
|
if (_isRunning || _isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_isRunning = true;
|
||||||
|
final int workerCount = maxConcurrent < 1 ? 1 : maxConcurrent;
|
||||||
|
final List<Future<void>> workers = List<Future<void>>.generate(
|
||||||
|
workerCount,
|
||||||
|
(_) => _uploadWorker(),
|
||||||
|
);
|
||||||
|
await Future.wait(workers);
|
||||||
|
_isRunning = false;
|
||||||
|
await _notifyCompletedIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<VideoBatchUploadTask>> pickVideos(
|
||||||
|
BuildContext context, {
|
||||||
|
int maxAssets = VideoBatchUploadConfig.maxAssets,
|
||||||
|
}) async {
|
||||||
|
final List<AssetEntity>? assets = await AssetPicker.pickAssets(
|
||||||
|
context,
|
||||||
|
pickerConfig: AssetPickerConfig(
|
||||||
|
maxAssets: maxAssets,
|
||||||
|
requestType: RequestType.video,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (assets == null || assets.isEmpty) {
|
||||||
|
return <VideoBatchUploadTask>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<VideoBatchUploadTask> tasks = <VideoBatchUploadTask>[];
|
||||||
|
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<String> segments = path.split(Platform.pathSeparator);
|
||||||
|
final String fileName = segments.isEmpty ? '' : segments.last.trim();
|
||||||
|
return fileName.isEmpty ? 'video.mp4' : fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> Function(
|
||||||
|
VideoBatchUploadTask task,
|
||||||
|
VideoBatchUploadProgressCallback onProgress,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef VideoBatchUploadSaveTask = Future<bool> 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<String, dynamic> toVideoPayload({
|
||||||
|
String urlKey = 'fileURL',
|
||||||
|
String fileNameKey = 'fileName',
|
||||||
|
String durationKey = 'duration',
|
||||||
|
}) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
urlKey: uploadUrl,
|
||||||
|
fileNameKey: fileName,
|
||||||
|
if (duration > 0) durationKey: duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toLibraryVideoPayload() {
|
||||||
|
return toVideoPayload();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user