feat: add video batch upload queue

This commit is contained in:
2026-05-29 16:17:26 +08:00
parent 1c971fb0b2
commit aa89e03bee
9 changed files with 1046 additions and 0 deletions
+4
View File
@@ -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;
}
}