mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
refactor(library): move queue download labels, dialogs and sliver grid out of queue_tab.dart
Pure moves: download label helpers and the download dialogs become parts of the queue_tab library, and the animated sliver grid becomes a public widget in lib/widgets since it uses no private state.
This commit is contained in:
1 parent
23c6621552
commit
96568f720a
5 files changed
+367
-358
No files matched your search
+3
-354
@@ -84,6 +84,7 @@ import 'package:spotiflac_android/screens/queue_library_refresh_policy.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/animated_library_sliver_grid.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
||||
import 'package:spotiflac_android/widgets/smoothed_progress.dart';
|
||||
@@ -99,110 +100,8 @@ part 'queue_tab_filter_widgets.dart';
|
||||
part 'queue_tab_sections.dart';
|
||||
part 'queue_tab_batch_actions.dart';
|
||||
part 'queue_tab_item_widgets.dart';
|
||||
|
||||
String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB';
|
||||
|
||||
String _formatDownloadProgressLabel(
|
||||
BuildContext context,
|
||||
DownloadItem item, {
|
||||
double? visualProgress,
|
||||
}) {
|
||||
final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0);
|
||||
final speedSuffix = item.speedMBps > 0
|
||||
? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: '';
|
||||
|
||||
if (item.bytesTotal > 0) {
|
||||
final received = visualProgress != null
|
||||
? item.bytesTotal * progress
|
||||
: item.bytesReceived > 0
|
||||
? item.bytesReceived
|
||||
: item.bytesTotal * progress;
|
||||
final percent = (progress * 100).toStringAsFixed(0);
|
||||
return '${_formatDownloadSizeMB(received)} / ${_formatDownloadSizeMB(item.bytesTotal)} • $percent%$speedSuffix';
|
||||
}
|
||||
|
||||
if (item.bytesReceived > 0) {
|
||||
final canEstimateTotal = progress > 0.01 && progress < 0.995;
|
||||
if (canEstimateTotal) {
|
||||
final estimatedTotal = item.bytesReceived / progress;
|
||||
if (estimatedTotal > item.bytesReceived) {
|
||||
return '${_formatDownloadSizeMB(item.bytesReceived)} / ~${_formatDownloadSizeMB(estimatedTotal)}$speedSuffix';
|
||||
}
|
||||
}
|
||||
return '${_formatDownloadSizeMB(item.bytesReceived)}$speedSuffix';
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
final percent = (progress * 100).toStringAsFixed(0);
|
||||
return '$percent%$speedSuffix';
|
||||
}
|
||||
|
||||
if (item.speedMBps > 0) {
|
||||
return context.l10n.queueDownloadSpeedStatus(
|
||||
item.speedMBps.toStringAsFixed(1),
|
||||
);
|
||||
}
|
||||
|
||||
if (item.error == 'Waiting for verification') {
|
||||
return context.l10n.queueWaitingForVerification;
|
||||
}
|
||||
switch (item.preparationStage) {
|
||||
case 'checking_session':
|
||||
return context.l10n.queueCheckingDownloadSession;
|
||||
case 'resolving_metadata':
|
||||
return context.l10n.queueResolvingDownloadMetadata;
|
||||
case 'resolving_stream':
|
||||
return context.l10n.queueResolvingDownloadStream;
|
||||
}
|
||||
if (item.error == 'Retrying after verification') {
|
||||
return context.l10n.queueResumingAfterVerification;
|
||||
}
|
||||
|
||||
return context.l10n.queueDownloadStarting;
|
||||
}
|
||||
|
||||
String _formatDownloadStatusLine(
|
||||
BuildContext context,
|
||||
DownloadItem item, {
|
||||
double? visualProgress,
|
||||
}) {
|
||||
final base = _formatDownloadProgressLabel(
|
||||
context,
|
||||
item,
|
||||
visualProgress: visualProgress,
|
||||
);
|
||||
final eta = _formatDownloadEta(item, visualProgress: visualProgress);
|
||||
return eta == null ? base : '$base • $eta';
|
||||
}
|
||||
|
||||
String? _formatDownloadEta(DownloadItem item, {double? visualProgress}) {
|
||||
if (item.speedMBps <= 0 || item.bytesTotal <= 0) return null;
|
||||
final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0);
|
||||
final received = visualProgress != null
|
||||
? (item.bytesTotal * progress).round()
|
||||
: item.bytesReceived > 0
|
||||
? item.bytesReceived
|
||||
: (item.bytesTotal * progress).round();
|
||||
final remaining = item.bytesTotal - received;
|
||||
if (remaining <= 0) return null;
|
||||
final seconds = remaining / (item.speedMBps * 1024 * 1024);
|
||||
if (!seconds.isFinite || seconds > 3600) return null;
|
||||
if (seconds < 60) return '~${seconds.round()}s';
|
||||
final minutes = (seconds / 60).floor();
|
||||
final secs = (seconds % 60).round();
|
||||
return '~${minutes}m${secs.toString().padLeft(2, '0')}s';
|
||||
}
|
||||
|
||||
bool _shouldAnimateDownloadProgress(BuildContext context, DownloadItem item) {
|
||||
final progress = item.progress.clamp(0.0, 1.0);
|
||||
final animationsDisabled =
|
||||
MediaQuery.maybeDisableAnimationsOf(context) ?? false;
|
||||
return item.status == DownloadStatus.downloading &&
|
||||
progress > 0 &&
|
||||
progress < 1 &&
|
||||
!animationsDisabled;
|
||||
}
|
||||
part 'queue_tab_download_labels.dart';
|
||||
part 'queue_tab_download_dialogs.dart';
|
||||
|
||||
DownloadHistoryItem? _historyItemForCompletionBridge(
|
||||
DownloadItem item,
|
||||
@@ -1848,254 +1747,4 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmCancelDownload(
|
||||
BuildContext context,
|
||||
DownloadItem item,
|
||||
) async {
|
||||
final confirmed = await showAppDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AppAlertDialog(
|
||||
title: Text(context.l10n.cancelDownloadTitle),
|
||||
content: Text(context.l10n.cancelDownloadContent(item.track.name)),
|
||||
actions: [
|
||||
AppDialogAction(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(context.l10n.cancelDownloadKeep),
|
||||
),
|
||||
AppDialogAction(
|
||||
isDestructive: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
// cancelItem (not dismissItem): the item stays in the queue as
|
||||
// cancelled so it can be retried without re-searching the track.
|
||||
ref.read(downloadQueueProvider.notifier).cancelItem(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showDownloadErrorDialog(
|
||||
BuildContext context,
|
||||
DownloadItem item,
|
||||
) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isRateLimit = item.errorType == DownloadErrorType.rateLimit;
|
||||
final isCancelled = item.status == DownloadStatus.skipped;
|
||||
final isFolderAccessLost =
|
||||
item.errorMessage == safPermissionLostErrorMessage ||
|
||||
item.errorMessage == downloadFolderAccessLostErrorMessage;
|
||||
final title = isCancelled
|
||||
? context.l10n.queueCancelledTitle
|
||||
: isRateLimit
|
||||
? context.l10n.queueRateLimitTitle
|
||||
: context.l10n.updateDownloadFailed;
|
||||
final message = isCancelled
|
||||
? context.l10n.queueCancelledMessage
|
||||
: isRateLimit
|
||||
? context.l10n.queueRateLimitMessage
|
||||
: (item.errorMessage.trim().isNotEmpty
|
||||
? _localizedDownloadError(context, item.errorMessage)
|
||||
: context.l10n.updateDownloadFailed);
|
||||
final action = await showAppDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AppAlertDialog(
|
||||
title: Text(title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.track.name,
|
||||
style: Theme.of(
|
||||
ctx,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SelectableText(
|
||||
message,
|
||||
style: Theme.of(ctx).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
AppDialogAction(
|
||||
isDestructive: true,
|
||||
onPressed: () => Navigator.of(ctx).pop('remove'),
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
|
||||
child: Text(context.l10n.dialogRemove),
|
||||
),
|
||||
AppDialogAction(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
if (isFolderAccessLost)
|
||||
AppDialogAction(
|
||||
filled: true,
|
||||
onPressed: () => Navigator.of(ctx).pop('reselect'),
|
||||
child: Text(context.l10n.downloadFolderReselect),
|
||||
)
|
||||
else
|
||||
AppDialogAction(
|
||||
filled: true,
|
||||
onPressed: () => Navigator.of(ctx).pop('retry'),
|
||||
child: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (action == 'retry') {
|
||||
ref.read(downloadQueueProvider.notifier).retryItem(item.id);
|
||||
} else if (action == 'remove') {
|
||||
ref.read(downloadQueueProvider.notifier).removeItem(item.id);
|
||||
} else if (action == 'reselect') {
|
||||
final reselected = await _reselectDownloadFolder();
|
||||
if (reselected && mounted) {
|
||||
ref.read(downloadQueueProvider.notifier).retryItem(item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reopen the platform folder picker to restore download folder access,
|
||||
/// then persist the new location. Returns true when a folder was saved.
|
||||
Future<bool> _reselectDownloadFolder() async {
|
||||
if (Platform.isAndroid) {
|
||||
final result = await PlatformBridge.pickSafTree();
|
||||
if (result == null) return false;
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
final displayName = result['display_name'] as String? ?? '';
|
||||
if (treeUri.isEmpty) return false;
|
||||
final notifier = ref.read(settingsProvider.notifier);
|
||||
notifier.setStorageMode('saf');
|
||||
notifier.setDownloadTreeUri(
|
||||
treeUri,
|
||||
displayName: displayName.isNotEmpty ? displayName : treeUri,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
IosPickedDirectory? picked;
|
||||
try {
|
||||
picked = await PlatformBridge.pickIosDirectory();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarFolderPickerFailed(
|
||||
context.friendlyError(e),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (picked == null || !mounted) return false;
|
||||
final validation = validateIosPath(
|
||||
picked.path,
|
||||
bookmark: picked.bookmark,
|
||||
);
|
||||
if (!validation.isValid) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
validation.errorReason ?? context.l10n.setupIcloudNotSupported,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadDirectory(picked.path, iosBookmark: picked.bookmark);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedLibrarySliverGrid extends StatefulWidget {
|
||||
final double maxCrossAxisExtent;
|
||||
final double mainAxisSpacing;
|
||||
final double crossAxisSpacing;
|
||||
final double childAspectRatio;
|
||||
final SliverChildDelegate delegate;
|
||||
|
||||
const _AnimatedLibrarySliverGrid({
|
||||
required this.maxCrossAxisExtent,
|
||||
required this.mainAxisSpacing,
|
||||
required this.crossAxisSpacing,
|
||||
required this.childAspectRatio,
|
||||
required this.delegate,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AnimatedLibrarySliverGrid> createState() =>
|
||||
_AnimatedLibrarySliverGridState();
|
||||
}
|
||||
|
||||
class _AnimatedLibrarySliverGridState extends State<_AnimatedLibrarySliverGrid>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final CurvedAnimation _curve;
|
||||
late double _beginExtent;
|
||||
late double _endExtent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_beginExtent = widget.maxCrossAxisExtent;
|
||||
_endExtent = widget.maxCrossAxisExtent;
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 130),
|
||||
)..value = 1;
|
||||
_curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _AnimatedLibrarySliverGrid oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if ((widget.maxCrossAxisExtent - _endExtent).abs() < 0.1) return;
|
||||
_beginExtent = _currentExtent;
|
||||
_endExtent = widget.maxCrossAxisExtent;
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
|
||||
double get _currentExtent =>
|
||||
_beginExtent + ((_endExtent - _beginExtent) * _curve.value);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_curve.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _currentExtent,
|
||||
mainAxisSpacing: widget.mainAxisSpacing,
|
||||
crossAxisSpacing: widget.crossAxisSpacing,
|
||||
childAspectRatio: widget.childAspectRatio,
|
||||
),
|
||||
delegate: widget.delegate,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
part of 'queue_tab.dart';
|
||||
|
||||
extension _QueueTabDownloadDialogs on _QueueTabState {
|
||||
Future<void> _confirmCancelDownload(
|
||||
BuildContext context,
|
||||
DownloadItem item,
|
||||
) async {
|
||||
final confirmed = await showAppDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AppAlertDialog(
|
||||
title: Text(context.l10n.cancelDownloadTitle),
|
||||
content: Text(context.l10n.cancelDownloadContent(item.track.name)),
|
||||
actions: [
|
||||
AppDialogAction(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(context.l10n.cancelDownloadKeep),
|
||||
),
|
||||
AppDialogAction(
|
||||
isDestructive: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
// cancelItem (not dismissItem): the item stays in the queue as
|
||||
// cancelled so it can be retried without re-searching the track.
|
||||
ref.read(downloadQueueProvider.notifier).cancelItem(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showDownloadErrorDialog(
|
||||
BuildContext context,
|
||||
DownloadItem item,
|
||||
) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isRateLimit = item.errorType == DownloadErrorType.rateLimit;
|
||||
final isCancelled = item.status == DownloadStatus.skipped;
|
||||
final isFolderAccessLost =
|
||||
item.errorMessage == safPermissionLostErrorMessage ||
|
||||
item.errorMessage == downloadFolderAccessLostErrorMessage;
|
||||
final title = isCancelled
|
||||
? context.l10n.queueCancelledTitle
|
||||
: isRateLimit
|
||||
? context.l10n.queueRateLimitTitle
|
||||
: context.l10n.updateDownloadFailed;
|
||||
final message = isCancelled
|
||||
? context.l10n.queueCancelledMessage
|
||||
: isRateLimit
|
||||
? context.l10n.queueRateLimitMessage
|
||||
: (item.errorMessage.trim().isNotEmpty
|
||||
? _localizedDownloadError(context, item.errorMessage)
|
||||
: context.l10n.updateDownloadFailed);
|
||||
final action = await showAppDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AppAlertDialog(
|
||||
title: Text(title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.track.name,
|
||||
style: Theme.of(
|
||||
ctx,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SelectableText(
|
||||
message,
|
||||
style: Theme.of(ctx).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
AppDialogAction(
|
||||
isDestructive: true,
|
||||
onPressed: () => Navigator.of(ctx).pop('remove'),
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
|
||||
child: Text(context.l10n.dialogRemove),
|
||||
),
|
||||
AppDialogAction(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
if (isFolderAccessLost)
|
||||
AppDialogAction(
|
||||
filled: true,
|
||||
onPressed: () => Navigator.of(ctx).pop('reselect'),
|
||||
child: Text(context.l10n.downloadFolderReselect),
|
||||
)
|
||||
else
|
||||
AppDialogAction(
|
||||
filled: true,
|
||||
onPressed: () => Navigator.of(ctx).pop('retry'),
|
||||
child: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (action == 'retry') {
|
||||
ref.read(downloadQueueProvider.notifier).retryItem(item.id);
|
||||
} else if (action == 'remove') {
|
||||
ref.read(downloadQueueProvider.notifier).removeItem(item.id);
|
||||
} else if (action == 'reselect') {
|
||||
final reselected = await _reselectDownloadFolder();
|
||||
if (reselected && mounted) {
|
||||
ref.read(downloadQueueProvider.notifier).retryItem(item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reopen the platform folder picker to restore download folder access,
|
||||
/// then persist the new location. Returns true when a folder was saved.
|
||||
Future<bool> _reselectDownloadFolder() async {
|
||||
if (Platform.isAndroid) {
|
||||
final result = await PlatformBridge.pickSafTree();
|
||||
if (result == null) return false;
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
final displayName = result['display_name'] as String? ?? '';
|
||||
if (treeUri.isEmpty) return false;
|
||||
final notifier = ref.read(settingsProvider.notifier);
|
||||
notifier.setStorageMode('saf');
|
||||
notifier.setDownloadTreeUri(
|
||||
treeUri,
|
||||
displayName: displayName.isNotEmpty ? displayName : treeUri,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
IosPickedDirectory? picked;
|
||||
try {
|
||||
picked = await PlatformBridge.pickIosDirectory();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarFolderPickerFailed(
|
||||
context.friendlyError(e),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (picked == null || !mounted) return false;
|
||||
final validation = validateIosPath(
|
||||
picked.path,
|
||||
bookmark: picked.bookmark,
|
||||
);
|
||||
if (!validation.isValid) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
validation.errorReason ?? context.l10n.setupIcloudNotSupported,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadDirectory(picked.path, iosBookmark: picked.bookmark);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
part of 'queue_tab.dart';
|
||||
|
||||
String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB';
|
||||
|
||||
String _formatDownloadProgressLabel(
|
||||
BuildContext context,
|
||||
DownloadItem item, {
|
||||
double? visualProgress,
|
||||
}) {
|
||||
final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0);
|
||||
final speedSuffix = item.speedMBps > 0
|
||||
? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: '';
|
||||
|
||||
if (item.bytesTotal > 0) {
|
||||
final received = visualProgress != null
|
||||
? item.bytesTotal * progress
|
||||
: item.bytesReceived > 0
|
||||
? item.bytesReceived
|
||||
: item.bytesTotal * progress;
|
||||
final percent = (progress * 100).toStringAsFixed(0);
|
||||
return '${_formatDownloadSizeMB(received)} / ${_formatDownloadSizeMB(item.bytesTotal)} • $percent%$speedSuffix';
|
||||
}
|
||||
|
||||
if (item.bytesReceived > 0) {
|
||||
final canEstimateTotal = progress > 0.01 && progress < 0.995;
|
||||
if (canEstimateTotal) {
|
||||
final estimatedTotal = item.bytesReceived / progress;
|
||||
if (estimatedTotal > item.bytesReceived) {
|
||||
return '${_formatDownloadSizeMB(item.bytesReceived)} / ~${_formatDownloadSizeMB(estimatedTotal)}$speedSuffix';
|
||||
}
|
||||
}
|
||||
return '${_formatDownloadSizeMB(item.bytesReceived)}$speedSuffix';
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
final percent = (progress * 100).toStringAsFixed(0);
|
||||
return '$percent%$speedSuffix';
|
||||
}
|
||||
|
||||
if (item.speedMBps > 0) {
|
||||
return context.l10n.queueDownloadSpeedStatus(
|
||||
item.speedMBps.toStringAsFixed(1),
|
||||
);
|
||||
}
|
||||
|
||||
if (item.error == 'Waiting for verification') {
|
||||
return context.l10n.queueWaitingForVerification;
|
||||
}
|
||||
switch (item.preparationStage) {
|
||||
case 'checking_session':
|
||||
return context.l10n.queueCheckingDownloadSession;
|
||||
case 'resolving_metadata':
|
||||
return context.l10n.queueResolvingDownloadMetadata;
|
||||
case 'resolving_stream':
|
||||
return context.l10n.queueResolvingDownloadStream;
|
||||
}
|
||||
if (item.error == 'Retrying after verification') {
|
||||
return context.l10n.queueResumingAfterVerification;
|
||||
}
|
||||
|
||||
return context.l10n.queueDownloadStarting;
|
||||
}
|
||||
|
||||
String _formatDownloadStatusLine(
|
||||
BuildContext context,
|
||||
DownloadItem item, {
|
||||
double? visualProgress,
|
||||
}) {
|
||||
final base = _formatDownloadProgressLabel(
|
||||
context,
|
||||
item,
|
||||
visualProgress: visualProgress,
|
||||
);
|
||||
final eta = _formatDownloadEta(item, visualProgress: visualProgress);
|
||||
return eta == null ? base : '$base • $eta';
|
||||
}
|
||||
|
||||
String? _formatDownloadEta(DownloadItem item, {double? visualProgress}) {
|
||||
if (item.speedMBps <= 0 || item.bytesTotal <= 0) return null;
|
||||
final progress = (visualProgress ?? item.progress).clamp(0.0, 1.0);
|
||||
final received = visualProgress != null
|
||||
? (item.bytesTotal * progress).round()
|
||||
: item.bytesReceived > 0
|
||||
? item.bytesReceived
|
||||
: (item.bytesTotal * progress).round();
|
||||
final remaining = item.bytesTotal - received;
|
||||
if (remaining <= 0) return null;
|
||||
final seconds = remaining / (item.speedMBps * 1024 * 1024);
|
||||
if (!seconds.isFinite || seconds > 3600) return null;
|
||||
if (seconds < 60) return '~${seconds.round()}s';
|
||||
final minutes = (seconds / 60).floor();
|
||||
final secs = (seconds % 60).round();
|
||||
return '~${minutes}m${secs.toString().padLeft(2, '0')}s';
|
||||
}
|
||||
|
||||
bool _shouldAnimateDownloadProgress(BuildContext context, DownloadItem item) {
|
||||
final progress = item.progress.clamp(0.0, 1.0);
|
||||
final animationsDisabled =
|
||||
MediaQuery.maybeDisableAnimationsOf(context) ?? false;
|
||||
return item.status == DownloadStatus.downloading &&
|
||||
progress > 0 &&
|
||||
progress < 1 &&
|
||||
!animationsDisabled;
|
||||
}
|
||||
@@ -466,7 +466,7 @@ extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
filteredGroupedLocalAlbums.isNotEmpty))
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: edge),
|
||||
sliver: _AnimatedLibrarySliverGrid(
|
||||
sliver: AnimatedLibrarySliverGrid(
|
||||
maxCrossAxisExtent: _libraryAlbumGridExtent,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
@@ -521,7 +521,7 @@ extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: edge),
|
||||
sliver: _AnimatedLibrarySliverGrid(
|
||||
sliver: AnimatedLibrarySliverGrid(
|
||||
maxCrossAxisExtent: _libraryAlbumGridExtent,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
@@ -562,7 +562,7 @@ extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
if (historyViewMode == 'grid')
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: edge),
|
||||
sliver: _AnimatedLibrarySliverGrid(
|
||||
sliver: AnimatedLibrarySliverGrid(
|
||||
maxCrossAxisExtent: _libraryGridExtent,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
@@ -746,7 +746,7 @@ extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
historyViewMode == 'grid'
|
||||
? SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: edge),
|
||||
sliver: _AnimatedLibrarySliverGrid(
|
||||
sliver: AnimatedLibrarySliverGrid(
|
||||
maxCrossAxisExtent: _libraryGridExtent,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnimatedLibrarySliverGrid extends StatefulWidget {
|
||||
final double maxCrossAxisExtent;
|
||||
final double mainAxisSpacing;
|
||||
final double crossAxisSpacing;
|
||||
final double childAspectRatio;
|
||||
final SliverChildDelegate delegate;
|
||||
|
||||
const AnimatedLibrarySliverGrid({
|
||||
super.key,
|
||||
required this.maxCrossAxisExtent,
|
||||
required this.mainAxisSpacing,
|
||||
required this.crossAxisSpacing,
|
||||
required this.childAspectRatio,
|
||||
required this.delegate,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedLibrarySliverGrid> createState() =>
|
||||
_AnimatedLibrarySliverGridState();
|
||||
}
|
||||
|
||||
class _AnimatedLibrarySliverGridState extends State<AnimatedLibrarySliverGrid>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final CurvedAnimation _curve;
|
||||
late double _beginExtent;
|
||||
late double _endExtent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_beginExtent = widget.maxCrossAxisExtent;
|
||||
_endExtent = widget.maxCrossAxisExtent;
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 130),
|
||||
)..value = 1;
|
||||
_curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AnimatedLibrarySliverGrid oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if ((widget.maxCrossAxisExtent - _endExtent).abs() < 0.1) return;
|
||||
_beginExtent = _currentExtent;
|
||||
_endExtent = widget.maxCrossAxisExtent;
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
|
||||
double get _currentExtent =>
|
||||
_beginExtent + ((_endExtent - _beginExtent) * _curve.value);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_curve.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _currentExtent,
|
||||
mainAxisSpacing: widget.mainAxisSpacing,
|
||||
crossAxisSpacing: widget.crossAxisSpacing,
|
||||
childAspectRatio: widget.childAspectRatio,
|
||||
),
|
||||
delegate: widget.delegate,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user