diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index f42046b2..2587cdc2 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; -import 'package:spotiflac_android/widgets/app_action_button.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; @@ -16,6 +15,7 @@ import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/library_search_results.dart'; +import 'package:spotiflac_android/widgets/library_track_selection_bar.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -56,7 +56,6 @@ 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/animation_utils.dart'; -import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/smoothed_progress.dart'; import 'package:spotiflac_android/widgets/scroll_edge_fade.dart'; diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 22afdc1e..790e8224 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -11,11 +11,8 @@ extension _QueueTabItemWidgets on _QueueTabState { final allSelected = selectedCount == unifiedItems.length && unifiedItems.isNotEmpty; final localOnlySelection = _isLocalOnlySelection(unifiedItems); - final flacEligibleCount = _selectedFlacEligibleLocalItems( - unifiedItems, - ).length; - return SelectionBottomBar( + return LibraryTrackSelectionBar( selectedCount: selectedCount, allSelected: allSelected, onClose: _exitSelectionMode, @@ -27,106 +24,15 @@ extension _QueueTabItemWidgets on _QueueTabState { } }, bottomPadding: bottomPadding, - children: [ - LayoutBuilder( - builder: (context, constraints) { - const spacing = 8.0; - final columns = (constraints.maxWidth / 320).floor().clamp(2, 4); - final itemWidth = - (constraints.maxWidth - spacing * (columns - 1)) / columns; - final actions = []; - - if (localOnlySelection && flacEligibleCount > 0) { - actions.add( - SelectionActionButton( - icon: Icons.download_for_offline_outlined, - label: '${context.l10n.queueFlacAction} ($flacEligibleCount)', - onPressed: () => _queueSelectedLocalAsFlac(unifiedItems), - colorScheme: colorScheme, - ), - ); - } - - actions.add( - SelectionActionButton( - icon: Icons.auto_fix_high_outlined, - label: '${context.l10n.trackReEnrich} ($selectedCount)', - onPressed: selectedCount > 0 - ? () => _reEnrichSelectedFromQueue(unifiedItems) - : null, - colorScheme: colorScheme, - ), - ); - - actions.add( - SelectionActionButton( - icon: Icons.swap_horiz, - label: context.l10n.selectionConvertCount(selectedCount), - onPressed: selectedCount > 0 - ? () => _showBatchConvertSheet(context, unifiedItems) - : null, - colorScheme: colorScheme, - ), - ); - - for (final remove in [false, true]) { - actions.add( - SelectionActionButton( - icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq, - label: remove - ? context.l10n.selectionRemoveReplayGainCount( - selectedCount, - ) - : context.l10n.selectionReplayGainCount(selectedCount), - onPressed: selectedCount > 0 - ? () => _runBatchReplayGain(unifiedItems, remove: remove) - : null, - colorScheme: colorScheme, - ), - ); - } - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: [ - for (final action in actions) - SizedBox(width: itemWidth, child: action), - ], - ); - }, - ), - - const SizedBox(height: 8), - - SizedBox( - width: double.infinity, - child: AppActionButton( - isDestructive: true, - onPressed: selectedCount > 0 - ? () => _deleteSelected(unifiedItems) - : null, - icon: const Icon(Icons.delete_outline), - label: Text( - selectedCount > 0 - ? context.l10n.selectionDeleteTracksCount(selectedCount) - : context.l10n.selectionSelectToDelete, - ), - style: FilledButton.styleFrom( - backgroundColor: selectedCount > 0 - ? colorScheme.error - : colorScheme.surfaceContainerHighest, - foregroundColor: selectedCount > 0 - ? colorScheme.onError - : colorScheme.onSurfaceVariant, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), - ), - ], + flacEligibleCount: localOnlySelection + ? _selectedFlacEligibleLocalItems(unifiedItems).length + : 0, + onQueueFlac: () => _queueSelectedLocalAsFlac(unifiedItems), + onReEnrich: () => _reEnrichSelectedFromQueue(unifiedItems), + onConvert: () => _showBatchConvertSheet(context, unifiedItems), + onReplayGain: ({required remove}) => + _runBatchReplayGain(unifiedItems, remove: remove), + onDelete: () => _deleteSelected(unifiedItems), ); } diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index b674c047..2ddbe643 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -402,72 +402,14 @@ extension _QueueTabSelectionActions on _QueueTabState { ); } - Future _deleteSelected(List allItems) async { - final count = _selectedIds.length; - final confirmed = await showAppDialog( - context: context, - builder: (ctx) => AppAlertDialog( - title: Text(context.l10n.dialogDeleteSelectedTitle), - content: Text(context.l10n.dialogDeleteSelectedMessage(count)), - actions: [ - AppDialogAction( - isDefault: true, - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - AppDialogAction( - filled: true, - isDestructive: true, - onPressed: () => Navigator.pop(ctx, true), - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - ), - child: Text(context.l10n.dialogDelete), - ), - ], - ), - ); - - if (confirmed == true && mounted) { - final historyNotifier = ref.read(downloadHistoryProvider.notifier); - final localLibraryDb = LibraryDatabase.instance; - final itemsById = {for (final item in allItems) item.id: item}; - - int deletedCount = 0; - for (final id in _selectedIds) { - final item = itemsById[id]; - if (item != null) { - final cleanPath = _cleanFilePath(item.filePath); - final fileDeleted = await deleteFile(cleanPath); - if (!fileDeleted) continue; - - if (item.source == LibraryItemSource.downloaded) { - historyNotifier.removeFromHistory(item.historyItem!.id); - } else { - await localLibraryDb.deleteByPath(item.filePath); - } - deletedCount++; - } - } - - if (allItems.any( - (i) => - _selectedIds.contains(i.id) && i.source == LibraryItemSource.local, - )) { - ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - - _exitSelectionMode(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarDeletedTracks(deletedCount)), - ), - ); - } - } - } + Future _deleteSelected(List allItems) => + deleteLibraryTracks( + context, + ref, + _selectedItemsFromAll(allItems), + isActive: () => mounted, + onComplete: _exitSelectionMode, + ); String _cleanFilePath(String? filePath) { return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath); diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart index 8ecfa845..95bb75f5 100644 --- a/lib/services/local_track_batch_actions.dart +++ b/lib/services/local_track_batch_actions.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -508,3 +509,63 @@ Future<({List tracks, int skipped})> matchLocalTracksForFlac( } return (tracks: tracks, skipped: skipped); } + +/// Confirms, then deletes the files behind [selected] and removes their +/// history or local-index rows. Files that cannot be deleted keep their rows. +Future deleteLibraryTracks( + BuildContext context, + WidgetRef ref, + List selected, { + required bool Function() isActive, + required VoidCallback onComplete, +}) async { + if (selected.isEmpty) return; + final confirmed = await showAppDialog( + context: context, + builder: (ctx) => AppAlertDialog( + title: Text(context.l10n.dialogDeleteSelectedTitle), + content: Text(context.l10n.dialogDeleteSelectedMessage(selected.length)), + actions: [ + AppDialogAction( + isDefault: true, + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + AppDialogAction( + filled: true, + isDestructive: true, + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(context.l10n.dialogDelete), + ), + ], + ), + ); + if (confirmed != true || !isActive() || !context.mounted) return; + + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + final messenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + var deletedCount = 0; + for (final item in selected) { + final cleanPath = DownloadedEmbeddedCoverResolver.cleanFilePath( + item.filePath, + ); + if (!await deleteFile(cleanPath)) continue; + if (item.source == LibraryItemSource.downloaded) { + historyNotifier.removeFromHistory(item.historyItem!.id); + } else { + await LibraryDatabase.instance.deleteByPath(item.filePath); + } + deletedCount++; + } + if (selected.any((item) => item.source == LibraryItemSource.local)) { + ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + onComplete(); + messenger.showSnackBar( + SnackBar(content: Text(l10n.snackbarDeletedTracks(deletedCount))), + ); +} diff --git a/lib/widgets/library_track_selection_bar.dart b/lib/widgets/library_track_selection_bar.dart new file mode 100644 index 00000000..303b38f9 --- /dev/null +++ b/lib/widgets/library_track_selection_bar.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; + +/// Multi-select toolbar for Library tracks: FLAC upgrade (local selections +/// only), re-enrich, convert, ReplayGain add/remove and delete. Shared by the +/// Library list and Library search results so both expose the same actions. +class LibraryTrackSelectionBar extends StatelessWidget { + const LibraryTrackSelectionBar({ + super.key, + required this.selectedCount, + required this.allSelected, + required this.onClose, + required this.onToggleSelectAll, + required this.bottomPadding, + required this.onReEnrich, + required this.onConvert, + required this.onReplayGain, + required this.onDelete, + this.flacEligibleCount = 0, + this.onQueueFlac, + }); + + final int selectedCount; + final bool allSelected; + final VoidCallback onClose; + final VoidCallback onToggleSelectAll; + final double bottomPadding; + final VoidCallback onReEnrich; + final VoidCallback onConvert; + final void Function({required bool remove}) onReplayGain; + final VoidCallback onDelete; + + /// FLAC upgrade is offered when this is positive and [onQueueFlac] is set. + final int flacEligibleCount; + final VoidCallback? onQueueFlac; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final hasSelection = selectedCount > 0; + return SelectionBottomBar( + selectedCount: selectedCount, + allSelected: allSelected, + onClose: onClose, + onToggleSelectAll: onToggleSelectAll, + bottomPadding: bottomPadding, + children: [ + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final columns = (constraints.maxWidth / 320).floor().clamp(2, 4); + final itemWidth = + (constraints.maxWidth - spacing * (columns - 1)) / columns; + final actions = [ + if (onQueueFlac != null && flacEligibleCount > 0) + SelectionActionButton( + icon: Icons.download_for_offline_outlined, + label: '${context.l10n.queueFlacAction} ($flacEligibleCount)', + onPressed: onQueueFlac, + colorScheme: colorScheme, + ), + SelectionActionButton( + icon: Icons.auto_fix_high_outlined, + label: '${context.l10n.trackReEnrich} ($selectedCount)', + onPressed: hasSelection ? onReEnrich : null, + colorScheme: colorScheme, + ), + SelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: hasSelection ? onConvert : null, + colorScheme: colorScheme, + ), + for (final remove in [false, true]) + SelectionActionButton( + icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq, + label: remove + ? context.l10n.selectionRemoveReplayGainCount( + selectedCount, + ) + : context.l10n.selectionReplayGainCount(selectedCount), + onPressed: hasSelection + ? () => onReplayGain(remove: remove) + : null, + colorScheme: colorScheme, + ), + ]; + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox(width: itemWidth, child: action), + ], + ); + }, + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: AppActionButton( + isDestructive: true, + onPressed: hasSelection ? onDelete : null, + icon: const Icon(Icons.delete_outline), + label: Text( + hasSelection + ? context.l10n.selectionDeleteTracksCount(selectedCount) + : context.l10n.selectionSelectToDelete, + ), + style: FilledButton.styleFrom( + backgroundColor: hasSelection + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: hasSelection + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ], + ); + } +}