diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 148b7fd0..1b6e373a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -206,22 +206,24 @@ class SettingsNotifier extends Notifier { unawaited(syncLyricsSettingsToBackend()); } - Future syncLyricsSettingsToBackend() async { + Future syncLyricsSettingsToBackend({AppSettings? settings}) async { + final snapshot = settings ?? state; if (!PlatformBridge.supportsCoreBackend) return; try { - await PlatformBridge.setLyricsProviders(state.lyricsProviders); + await PlatformBridge.setLyricsProviders(snapshot.lyricsProviders); } catch (e) { _log.w('Failed to sync lyrics providers to backend: $e'); } try { await PlatformBridge.setLyricsFetchOptions({ - 'include_translation_netease': state.lyricsIncludeTranslationNetease, - 'include_romanization_netease': state.lyricsIncludeRomanizationNetease, - 'multi_person_word_by_word': state.lyricsMultiPersonWordByWord, - 'apple_elrc_word_sync': state.lyricsAppleElrcWordSync, - 'musixmatch_language': state.musixmatchLanguage, + 'include_translation_netease': snapshot.lyricsIncludeTranslationNetease, + 'include_romanization_netease': + snapshot.lyricsIncludeRomanizationNetease, + 'multi_person_word_by_word': snapshot.lyricsMultiPersonWordByWord, + 'apple_elrc_word_sync': snapshot.lyricsAppleElrcWordSync, + 'musixmatch_language': snapshot.musixmatchLanguage, }); } catch (e) { _log.w('Failed to sync lyrics fetch options to backend: $e'); diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 40de9f27..39026267 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1,30 +1,21 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/providers/download_queue_provider.dart'; -import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; -import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; -import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; -import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; -import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; -import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; -import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; @@ -432,41 +423,6 @@ class _LocalAlbumScreenState extends ConsumerState ); } - Future _reEnrichLocalTrack( - LocalLibraryItem item, { - required List updateFields, - required Map resolvedMetadata, - }) async { - final settings = ref.read(settingsProvider); - final artistTagMode = settings.artistTagMode; - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - final request = buildBatchReEnrichRequest( - item: item, - settings: settings, - updateFields: updateFields, - resolvedMetadata: resolvedMetadata, - ); - - final result = await PlatformBridge.reEnrichFile(request); - final method = result['method'] as String?; - if (method == 'native') { - // Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin). - await writeReEnrichSidecarLrc( - audioFilePath: item.filePath, - reEnrichResult: result, - ); - return true; - } - if (method == 'ffmpeg') { - return applyFfmpegReEnrichResult( - item: item, - result: result, - artistTagMode: artistTagMode, - ); - } - return false; - } - List _selectedFlacEligibleItems( List allTracks, ) { @@ -478,310 +434,31 @@ class _LocalAlbumScreenState extends ConsumerState .toList(growable: false); } - Future _queueSelectedAsFlac(List allTracks) async { - final selected = _selectedFlacEligibleItems(allTracks); - - if (selected.isEmpty) { - return; - } - - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.queueFlacAction), - content: Text(context.l10n.queueFlacConfirmMessage(selected.length)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.queueFlacAction), - ), - ], - ), - ); - - if (confirmed != true || !mounted) { - return; - } - - final settings = ref.read(settingsProvider); - final extensionState = ref.read(extensionProvider); - final includeExtensions = - settings.useExtensionProviders && - extensionState.extensions.any( - (ext) => ext.enabled && ext.hasMetadataProvider, - ); - final targetService = LocalTrackRedownloadService.preferredFlacService( - settings, - extensionState, - ); - if (targetService.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + Future _queueSelectedAsFlac(List allTracks) => + queueLocalTracksAsFlac( + context, + ref, + _selectedFlacEligibleItems(allTracks), + isActive: () => mounted, + onComplete: exitSelectionMode, ); - return; - } - final targetQuality = - LocalTrackRedownloadService.preferredFlacQualityForService( - targetService, - extensionState, - ); - - final matchedTracks = []; - var skippedCount = 0; - final total = selected.length; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.queueFlacAction, - total: total, - icon: Icons.queue_music, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - - BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName); - - try { - final resolution = await LocalTrackRedownloadService.resolveBestMatch( - selected[i], - includeExtensions: includeExtensions, - ); - if (resolution.canQueue && resolution.match != null) { - matchedTracks.add(resolution.match!); - } else { - skippedCount++; - } - } catch (_) { - skippedCount++; - } - } - - if (!mounted) { - return; - } - - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - - if (matchedTracks.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), - ); - return; - } - - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - matchedTracks, - targetService, - qualityOverride: targetQuality, - ); - - final summary = skippedCount == 0 - ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) - : context.l10n.queueFlacQueuedWithSkipped( - matchedTracks.length, - skippedCount, - ); - - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); - exitSelectionMode(); - } Future _reEnrichSelected(List allTracks) async { - final tracksById = {for (final t in allTracks) t.id: t}; - final selected = []; - - for (final id in selectedIds) { - final item = tracksById[id]; - if (item != null) { - selected.add(item); - } - } - - if (selected.isEmpty) { - return; - } - - // The bar uses AnimatedPositioned (250ms), so wait for the slide-out. - setState(() => isSelectionMode = false); - await Future.delayed(const Duration(milliseconds: 300)); - if (!mounted) return; - - final selection = await showReEnrichFieldDialog( + final tracksById = {for (final track in allTracks) track.id: track}; + final selected = [for (final id in selectedIds) ?tracksById[id]]; + await reEnrichLocalTracks( context, - selectedCount: selected.length, - ); - - if (selection == null || !mounted) { - // Cancelled — restore selection mode (IDs are still intact). - if (mounted) setState(() => isSelectionMode = true); - return; - } - - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - if (!mounted) return; - final settings = ref.read(settingsProvider); - final previews = []; - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: selection.usesManualValues - ? context.l10n.trackReEnrichPreparing - : context.l10n.trackReEnrichSearching, - total: selected.length, - icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); + ref, + selected, + isActive: () => mounted, + onSelectionHide: () async { + setState(() => isSelectionMode = false); + // Allow the selection bar's 250 ms slide-out to finish. + await Future.delayed(const Duration(milliseconds: 300)); }, + onSelectionRestore: () => setState(() => isSelectionMode = true), + onComplete: exitSelectionMode, ); - - for (var i = 0; i < selected.length; i++) { - if (!mounted || cancelled) break; - final item = selected[i]; - BatchProgressDialog.update( - current: i + 1, - detail: '${item.trackName} - ${item.artistName}', - ); - final updateFields = selection.updateFieldsFor(item); - if (updateFields.isEmpty) continue; - if (selection.usesManualValues) { - final preview = buildManualBatchReEnrichPreview(item, selection); - if (preview != null) previews.add(preview); - continue; - } - try { - final result = await PlatformBridge.reEnrichFile( - buildBatchReEnrichRequest( - item: item, - settings: settings, - updateFields: updateFields, - previewOnly: true, - ), - ); - final rawMetadata = result['enriched_metadata']; - if (result['method'] != 'preview' || rawMetadata is! Map) continue; - final enrichedMetadata = rawMetadata.map( - (key, value) => MapEntry(key.toString(), value), - ); - final changes = buildReEnrichMetadataChanges( - item, - enrichedMetadata, - updateFields, - ); - if (changes.isEmpty) continue; - previews.add( - BatchReEnrichPreview( - item: item, - updateFields: updateFields, - enrichedMetadata: enrichedMetadata, - changes: changes, - ), - ); - } catch (_) {} - } - - if (!mounted) return; - if (!cancelled) BatchProgressDialog.dismiss(context); - if (cancelled) { - setState(() => isSelectionMode = true); - return; - } - - if (previews.isEmpty) { - setState(() => isSelectionMode = true); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)), - ); - return; - } - - final confirmed = await showReEnrichReviewSheet( - context, - previews: previews, - ); - if (!confirmed || !mounted) { - if (mounted) setState(() => isSelectionMode = true); - return; - } - - var successCount = 0; - final total = previews.length; - cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.trackReEnrichProgress, - total: total, - icon: Icons.auto_fix_high, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final preview = previews[i]; - BatchProgressDialog.update( - current: i + 1, - detail: '${preview.item.trackName} - ${preview.item.artistName}', - ); - try { - final ok = await _reEnrichLocalTrack( - preview.item, - updateFields: preview.updateFields, - resolvedMetadata: preview.enrichedMetadata, - ); - if (ok) successCount++; - } catch (_) {} - } - - if (!mounted) return; - if (!cancelled) BatchProgressDialog.dismiss(context); - - try { - if (!ref.read(localLibraryProvider).isScanning) { - await ref.read(localLibraryProvider.notifier).scanAllSources(); - } else { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - } catch (_) { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - - exitSelectionMode(); - - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).clearSnackBars(); - final failedCount = total - successCount; - final summary = failedCount <= 0 - ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' - : context.l10n.trackReEnrichSuccessWithFailures( - successCount, - total, - failedCount, - ); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); } List _selectedUnifiedItems( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 023480a5..e04c813a 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; @@ -17,13 +18,10 @@ import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; import 'package:spotiflac_android/utils/file_access.dart'; -import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/ordered_range_selection.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; -import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -36,14 +34,10 @@ import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; -import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/favorite_artists_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; -import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; -import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; -import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; diff --git a/lib/screens/queue_tab_batch_actions.dart b/lib/screens/queue_tab_batch_actions.dart index c9e7c553..fd8494dc 100644 --- a/lib/screens/queue_tab_batch_actions.dart +++ b/lib/screens/queue_tab_batch_actions.dart @@ -1,41 +1,6 @@ part of 'queue_tab.dart'; extension _QueueTabBatchActions on _QueueTabState { - Future _reEnrichQueueLocalTrack( - LocalLibraryItem item, { - required List updateFields, - required Map resolvedMetadata, - }) async { - final settings = ref.read(settingsProvider); - final artistTagMode = settings.artistTagMode; - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - final request = buildBatchReEnrichRequest( - item: item, - settings: settings, - updateFields: updateFields, - resolvedMetadata: resolvedMetadata, - ); - - final result = await PlatformBridge.reEnrichFile(request); - final method = result['method'] as String?; - if (method == 'native') { - // Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin). - await writeReEnrichSidecarLrc( - audioFilePath: item.filePath, - reEnrichResult: result, - ); - return true; - } - if (method == 'ffmpeg') { - return applyFfmpegReEnrichResult( - item: item, - result: result, - artistTagMode: artistTagMode, - ); - } - return false; - } - List _selectedFlacEligibleLocalItems( List allItems, ) { @@ -47,321 +12,32 @@ extension _QueueTabBatchActions on _QueueTabState { .toList(growable: false); } - Future _queueSelectedLocalAsFlac( - List allItems, - ) async { - final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems); - - if (selectedLocalItems.isEmpty) { - return; - } - - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.queueFlacAction), - content: Text( - context.l10n.queueFlacConfirmMessage(selectedLocalItems.length), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.queueFlacAction), - ), - ], - ), - ); - - if (confirmed != true || !mounted) { - return; - } - - final settings = ref.read(settingsProvider); - final extensionState = ref.read(extensionProvider); - final includeExtensions = - settings.useExtensionProviders && - extensionState.extensions.any( - (ext) => ext.enabled && ext.hasMetadataProvider, - ); - final targetService = LocalTrackRedownloadService.preferredFlacService( - settings, - extensionState, - ); - if (targetService.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + Future _queueSelectedLocalAsFlac(List allItems) => + queueLocalTracksAsFlac( + context, + ref, + _selectedFlacEligibleLocalItems(allItems), + isActive: () => mounted, + onComplete: _exitSelectionMode, ); - return; - } - final targetQuality = - LocalTrackRedownloadService.preferredFlacQualityForService( - targetService, - extensionState, - ); - - final matchedTracks = []; - var skippedCount = 0; - final total = selectedLocalItems.length; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.queueFlacAction, - total: total, - icon: Icons.queue_music, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - - BatchProgressDialog.update( - current: i + 1, - detail: selectedLocalItems[i].trackName, - ); - - try { - final resolution = await LocalTrackRedownloadService.resolveBestMatch( - selectedLocalItems[i], - includeExtensions: includeExtensions, - ); - if (resolution.canQueue && resolution.match != null) { - matchedTracks.add(resolution.match!); - } else { - skippedCount++; - } - } catch (_) { - skippedCount++; - } - } - - if (!mounted) { - return; - } - - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - - if (matchedTracks.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), - ); - return; - } - - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - matchedTracks, - targetService, - qualityOverride: targetQuality, - ); - - final summary = skippedCount == 0 - ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) - : context.l10n.queueFlacQueuedWithSkipped( - matchedTracks.length, - skippedCount, - ); - - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); - _setState(() { - _selectedIds.clear(); - _isSelectionMode = false; - _selectionAnchorId = null; - }); - } Future _reEnrichSelectedLocalFromQueue( List allItems, - ) async { - final selectedItems = _selectedItemsFromAll(allItems); - final selectedLocalItems = selectedItems + ) => reEnrichLocalTracks( + context, + ref, + _selectedItemsFromAll(allItems) .map((item) => item.localItem) .whereType() - .toList(growable: false); - - if (selectedLocalItems.isEmpty) { - return; - } - - // Hide the selection overlay: set the flag (prevents build() from - // re-inserting via postFrameCallback) and remove the entry immediately. - _setState(() => _isSelectionMode = false); - _hideSelectionOverlay(); - - final selection = await showReEnrichFieldDialog( - context, - selectedCount: selectedLocalItems.length, - ); - - if (selection == null || !mounted) { - // Cancelled — restore selection mode; the next build cycle will - // re-create the overlay via _syncSelectionOverlay in postFrameCallback. - if (mounted) _setState(() => _isSelectionMode = true); - return; - } - - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - if (!mounted) return; - final settings = ref.read(settingsProvider); - final previews = []; - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: selection.usesManualValues - ? context.l10n.trackReEnrichPreparing - : context.l10n.trackReEnrichSearching, - total: selectedLocalItems.length, - icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < selectedLocalItems.length; i++) { - if (!mounted || cancelled) break; - final item = selectedLocalItems[i]; - BatchProgressDialog.update( - current: i + 1, - detail: '${item.trackName} - ${item.artistName}', - ); - final updateFields = selection.updateFieldsFor(item); - if (updateFields.isEmpty) continue; - if (selection.usesManualValues) { - final preview = buildManualBatchReEnrichPreview(item, selection); - if (preview != null) previews.add(preview); - continue; - } - try { - final result = await PlatformBridge.reEnrichFile( - buildBatchReEnrichRequest( - item: item, - settings: settings, - updateFields: updateFields, - previewOnly: true, - ), - ); - final rawMetadata = result['enriched_metadata']; - if (result['method'] != 'preview' || rawMetadata is! Map) continue; - final enrichedMetadata = rawMetadata.map( - (key, value) => MapEntry(key.toString(), value), - ); - final changes = buildReEnrichMetadataChanges( - item, - enrichedMetadata, - updateFields, - ); - if (changes.isEmpty) continue; - previews.add( - BatchReEnrichPreview( - item: item, - updateFields: updateFields, - enrichedMetadata: enrichedMetadata, - changes: changes, - ), - ); - } catch (_) {} - } - - if (!mounted) return; - if (!cancelled) BatchProgressDialog.dismiss(context); - if (cancelled) { - _setState(() => _isSelectionMode = true); - return; - } - - if (previews.isEmpty) { - _setState(() => _isSelectionMode = true); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)), - ); - return; - } - - final confirmed = await showReEnrichReviewSheet( - context, - previews: previews, - ); - if (!confirmed || !mounted) { - if (mounted) _setState(() => _isSelectionMode = true); - return; - } - - var successCount = 0; - final total = previews.length; - cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.trackReEnrichProgress, - total: total, - icon: Icons.auto_fix_high, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final preview = previews[i]; - BatchProgressDialog.update( - current: i + 1, - detail: '${preview.item.trackName} - ${preview.item.artistName}', - ); - try { - final ok = await _reEnrichQueueLocalTrack( - preview.item, - updateFields: preview.updateFields, - resolvedMetadata: preview.enrichedMetadata, - ); - if (ok) successCount++; - } catch (_) {} - } - - if (!mounted) return; - if (!cancelled) BatchProgressDialog.dismiss(context); - - try { - if (!ref.read(localLibraryProvider).isScanning) { - await ref.read(localLibraryProvider.notifier).scanAllSources(); - } else { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - } catch (_) { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - - _exitSelectionMode(); - - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).clearSnackBars(); - final failedCount = total - successCount; - final summary = failedCount <= 0 - ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' - : context.l10n.trackReEnrichSuccessWithFailures( - successCount, - total, - failedCount, - ); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); - } + .toList(growable: false), + isActive: () => mounted, + onSelectionHide: () async { + _setState(() => _isSelectionMode = false); + _hideSelectionOverlay(); + }, + onSelectionRestore: () => _setState(() => _isSelectionMode = true), + onComplete: _exitSelectionMode, + ); /// Share selected tracks via system share sheet Future _shareSelected(List allItems) async { diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart new file mode 100644 index 00000000..e05f5dba --- /dev/null +++ b/lib/services/local_track_batch_actions.dart @@ -0,0 +1,414 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; +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/lyrics_metadata_helper.dart'; +import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; +import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; +import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; + +Future queueLocalTracksAsFlac( + BuildContext context, + WidgetRef ref, + List selected, { + required bool Function() isActive, + required VoidCallback onComplete, +}) async { + if (selected.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.queueFlacAction), + content: Text(context.l10n.queueFlacConfirmMessage(selected.length)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.queueFlacAction), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted || !isActive()) { + return; + } + + final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + final includeExtensions = + settings.useExtensionProviders && + extensionState.extensions.any( + (ext) => ext.enabled && ext.hasMetadataProvider, + ); + final targetService = LocalTrackRedownloadService.preferredFlacService( + settings, + extensionState, + ); + if (targetService.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } + final targetQuality = + LocalTrackRedownloadService.preferredFlacQualityForService( + targetService, + extensionState, + ); + + final total = selected.length; + + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.queueFlacAction, + total: total, + icon: Icons.queue_music, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + final matches = await matchLocalTracksForFlac( + selected, + resolve: (item) => LocalTrackRedownloadService.resolveBestMatch( + item, + includeExtensions: includeExtensions, + ), + shouldStop: () => cancelled || !context.mounted || !isActive(), + onProgress: (index, item) => + BatchProgressDialog.update(current: index + 1, detail: item.trackName), + ); + final matchedTracks = matches.tracks; + final skippedCount = matches.skipped; + + if (!context.mounted || !isActive()) { + return; + } + + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } + + if (matchedTracks.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), + ); + return; + } + + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + matchedTracks, + targetService, + qualityOverride: targetQuality, + ); + + final summary = skippedCount == 0 + ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) + : context.l10n.queueFlacQueuedWithSkipped( + matchedTracks.length, + skippedCount, + ); + + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(summary))); + onComplete(); +} + +Future reEnrichLocalTracks( + BuildContext context, + WidgetRef ref, + List selected, { + required bool Function() isActive, + required Future Function() onSelectionHide, + required VoidCallback onSelectionRestore, + required VoidCallback onComplete, +}) async { + if (selected.isEmpty) return; + // Capture a stable route context before a caller removes its overlay. + context = Navigator.of(context, rootNavigator: true).context; + await onSelectionHide(); + if (!context.mounted || !isActive()) return; + final selection = await showReEnrichFieldDialog( + context, + selectedCount: selected.length, + ); + + if (selection == null || !context.mounted || !isActive()) { + // Cancelled — restore selection mode (IDs are still intact). + if (context.mounted && isActive()) onSelectionRestore(); + return; + } + + final runner = BatchReEnrichRunner( + beginPhase: () async { + final settings = ref.read(settingsProvider); + await ref + .read(settingsProvider.notifier) + .syncLyricsSettingsToBackend(settings: settings); + return settings; + }, + ); + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: selection.usesManualValues + ? context.l10n.trackReEnrichPreparing + : context.l10n.trackReEnrichSearching, + total: selected.length, + icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + final previews = await runner.preview( + selected, + selection, + shouldStop: () => cancelled || !context.mounted || !isActive(), + onProgress: (index, item) => BatchProgressDialog.update( + current: index + 1, + detail: '${item.trackName} - ${item.artistName}', + ), + ); + + if (!context.mounted || !isActive()) return; + if (!cancelled) BatchProgressDialog.dismiss(context); + if (cancelled) { + onSelectionRestore(); + return; + } + + if (previews.isEmpty) { + onSelectionRestore(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)), + ); + return; + } + + final confirmed = await showReEnrichReviewSheet(context, previews: previews); + if (!confirmed || !context.mounted || !isActive()) { + if (context.mounted && isActive()) onSelectionRestore(); + return; + } + + final total = previews.length; + cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.trackReEnrichProgress, + total: total, + icon: Icons.auto_fix_high, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + final successCount = await runner.apply( + previews, + shouldStop: () => cancelled || !context.mounted || !isActive(), + onProgress: (index, item) => BatchProgressDialog.update( + current: index + 1, + detail: '${item.trackName} - ${item.artistName}', + ), + ); + + if (!context.mounted || !isActive()) return; + if (!cancelled) BatchProgressDialog.dismiss(context); + + try { + if (!ref.read(localLibraryProvider).isScanning) { + await ref.read(localLibraryProvider.notifier).scanAllSources(); + } else { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } catch (_) { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + if (!context.mounted || !isActive()) return; + onComplete(); + + ScaffoldMessenger.of(context).clearSnackBars(); + final failedCount = total - successCount; + final summary = failedCount <= 0 + ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' + : context.l10n.trackReEnrichSuccessWithFailures( + successCount, + total, + failedCount, + ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(summary))); +} + +/// Runs a batch against one settings snapshot per phase. Dependencies are +/// injectable so cancellation, dispatch, and partial failure can be verified +/// without writing media files or opening dialogs. +class BatchReEnrichRunner { + final Future Function() beginPhase; + final Future> Function(Map) reEnrich; + final Future Function({ + required String audioFilePath, + required Map reEnrichResult, + }) + writeSidecar; + final Future Function({ + required LocalLibraryItem item, + required Map result, + required String artistTagMode, + }) + applyFfmpeg; + + BatchReEnrichRunner({ + required this.beginPhase, + this.reEnrich = PlatformBridge.reEnrichFile, + this.writeSidecar = writeReEnrichSidecarLrc, + this.applyFfmpeg = applyFfmpegReEnrichResult, + }); + + Future> preview( + List items, + ReEnrichFieldSelection selection, { + required bool Function() shouldStop, + required void Function(int, LocalLibraryItem) onProgress, + }) async { + if (shouldStop()) return []; + final settings = await beginPhase(); + final previews = []; + for (var index = 0; index < items.length; index++) { + if (shouldStop()) break; + final item = items[index]; + onProgress(index, item); + final fields = selection.updateFieldsFor(item); + if (fields.isEmpty) continue; + if (selection.usesManualValues) { + final preview = buildManualBatchReEnrichPreview(item, selection); + if (preview != null) previews.add(preview); + continue; + } + try { + final result = await reEnrich( + buildBatchReEnrichRequest( + item: item, + settings: settings, + updateFields: fields, + previewOnly: true, + ), + ); + final rawMetadata = result['enriched_metadata']; + if (result['method'] != 'preview' || rawMetadata is! Map) continue; + final metadata = rawMetadata.map( + (key, value) => MapEntry(key.toString(), value), + ); + final changes = buildReEnrichMetadataChanges(item, metadata, fields); + if (changes.isEmpty) continue; + previews.add( + BatchReEnrichPreview( + item: item, + updateFields: fields, + enrichedMetadata: metadata, + changes: changes, + ), + ); + } catch (_) { + // A failed lookup must not prevent review of other tracks. + } + } + return previews; + } + + Future apply( + List previews, { + required bool Function() shouldStop, + required void Function(int, LocalLibraryItem) onProgress, + }) async { + if (shouldStop()) return 0; + // Settings may have changed while the review sheet was open. + final settings = await beginPhase(); + var successes = 0; + for (var index = 0; index < previews.length; index++) { + if (shouldStop()) break; + final preview = previews[index]; + onProgress(index, preview.item); + try { + final result = await reEnrich( + buildBatchReEnrichRequest( + item: preview.item, + settings: settings, + updateFields: preview.updateFields, + resolvedMetadata: preview.enrichedMetadata, + ), + ); + switch (result['method']) { + case 'native': + await writeSidecar( + audioFilePath: preview.item.filePath, + reEnrichResult: result, + ); + successes++; + case 'ffmpeg': + if (await applyFfmpeg( + item: preview.item, + result: result, + artistTagMode: settings.artistTagMode, + )) { + successes++; + } + } + } catch (_) { + // Keep successful files and continue with the rest of the selection. + } + } + return successes; + } +} + +/// Cancellation stops further matching and retains reliable matches already +/// resolved, matching the queue action's existing partial-completion behavior. +Future<({List tracks, int skipped})> matchLocalTracksForFlac( + List items, { + required Future Function(LocalLibraryItem) + resolve, + required bool Function() shouldStop, + required void Function(int, LocalLibraryItem) onProgress, +}) async { + final tracks = []; + var skipped = 0; + for (var index = 0; index < items.length; index++) { + if (shouldStop()) break; + onProgress(index, items[index]); + try { + final resolution = await resolve(items[index]); + if (resolution.canQueue && resolution.match != null) { + tracks.add(resolution.match!); + } else { + skipped++; + } + } catch (_) { + skipped++; + } + } + return (tracks: tracks, skipped: skipped); +} diff --git a/test/local_track_batch_actions_test.dart b/test/local_track_batch_actions_test.dart new file mode 100644 index 00000000..d0ade150 --- /dev/null +++ b/test/local_track_batch_actions_test.dart @@ -0,0 +1,389 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/local_track_batch_actions.dart'; +import 'package:spotiflac_android/services/local_track_redownload_service.dart'; + +LocalLibraryItem item(String id) => LocalLibraryItem( + id: id, + trackName: 'Song $id', + artistName: 'Artist', + albumName: 'Album', + filePath: '/music/$id.flac', + scannedAt: DateTime(2026), +); +void progress(int index, LocalLibraryItem item) {} +bool running() => false; + +class _BatchSettings extends SettingsNotifier { + final phases = []; + @override + AppSettings build() => const AppSettings(); + @override + Future syncLyricsSettingsToBackend({AppSettings? settings}) async { + phases.add(settings ?? state); + } +} + +class _BatchLibrary extends LocalLibraryNotifier { + final refreshed = Completer(); + bool refreshStarted = false; + @override + LocalLibraryState build() => LocalLibraryState(); + @override + Future scanAllSources({bool forceFullScan = false}) { + refreshStarted = true; + return refreshed.future; + } +} + +void main() { + test( + 'lookup/apply each capture settings once, dispatch native/ffmpeg, and retain partial success', + () async { + var phaseCount = 0; + var currentSettings = const AppSettings(lyricsMode: 'preview'); + final requests = >[]; + final sidecars = []; + final ffmpegModes = []; + final runner = BatchReEnrichRunner( + beginPhase: () async { + phaseCount++; + return currentSettings; + }, + reEnrich: (request) async { + requests.add(request); + if (request['preview_only'] == true) { + return { + 'method': 'preview', + 'enriched_metadata': {'isrc': 'USABC2600001'}, + }; + } + // A mid-phase change must not alter the captured apply settings. + currentSettings = currentSettings.copyWith( + artistTagMode: 'later', + lyricsMode: 'later', + ); + return switch (request['file_path']) { + '/music/1.flac' => {'method': 'native'}, + '/music/2.flac' => {'method': 'ffmpeg'}, + '/music/3.flac' => throw StateError('write failed'), + _ => {'method': 'unsupported'}, + }; + }, + writeSidecar: + ({required audioFilePath, required reEnrichResult}) async { + sidecars.add(audioFilePath); + }, + applyFfmpeg: + ({required item, required result, required artistTagMode}) async { + ffmpegModes.add(artistTagMode); + return true; + }, + ); + final previews = await runner.preview( + [item('1'), item('2'), item('3'), item('4')], + const ReEnrichFieldSelection(mode: ReEnrichBatchMode.isrcOnly), + shouldStop: running, + onProgress: progress, + ); + expect(previews.length, 4); + expect(phaseCount, 1); + currentSettings = const AppSettings( + lyricsMode: 'apply', + artistTagMode: 'split_vorbis', + ); + final successes = await runner.apply( + previews, + shouldStop: running, + onProgress: progress, + ); + expect(successes, 2); + expect(phaseCount, 2); + expect(sidecars, ['/music/1.flac']); + expect(ffmpegModes, ['split_vorbis']); + expect( + requests.take(4).map((request) => request['lyrics_mode']), + everyElement('preview'), + ); + expect( + requests.skip(4).map((request) => request['lyrics_mode']), + everyElement('apply'), + ); + expect( + requests.skip(4).map((request) => request['search_online']), + everyElement(false), + ); + }, + ); + + test( + 'manual previews avoid lookups; cancellation before apply performs no writes or phase sync', + () async { + var phases = 0; + var calls = 0; + final runner = BatchReEnrichRunner( + beginPhase: () async { + phases++; + return const AppSettings(); + }, + reEnrich: (_) async { + calls++; + return {}; + }, + ); + final previews = await runner.preview( + [item('1'), item('2')], + const ReEnrichFieldSelection( + mode: ReEnrichBatchMode.manualValues, + manualValues: {'genre': 'Jazz'}, + ), + shouldStop: running, + onProgress: progress, + ); + expect(previews.length, 2); + expect(calls, 0); + expect( + await runner.apply( + previews, + shouldStop: () => true, + onProgress: progress, + ), + 0, + ); + expect(calls, 0); + expect(phases, 1); + }, + ); + + test( + 'lookup failures do not discard useful previews and cancellation stops subsequent tracks', + () async { + var cancelled = false; + var lookups = 0; + final runner = BatchReEnrichRunner( + beginPhase: () async => const AppSettings(), + reEnrich: (_) async { + lookups++; + if (lookups == 1) throw StateError('lookup failed'); + cancelled = true; + return { + 'method': 'preview', + 'enriched_metadata': {'isrc': 'USABC2600001'}, + }; + }, + ); + final previews = await runner.preview( + [item('1'), item('2'), item('3')], + const ReEnrichFieldSelection(mode: ReEnrichBatchMode.isrcOnly), + shouldStop: () => cancelled, + onProgress: progress, + ); + expect(lookups, 2); + expect(previews.single.item.id, '2'); + }, + ); + + test( + 'FLAC matching retains reliable partial results and counts failures when cancelled', + () async { + var cancelled = false; + var calls = 0; + final result = await matchLocalTracksForFlac( + [item('1'), item('2'), item('3'), item('4'), item('5')], + shouldStop: () => cancelled, + onProgress: progress, + resolve: (local) async { + calls++; + if (local.id == '2') throw StateError('lookup failed'); + if (local.id == '4') cancelled = true; + return LocalTrackRedownloadResolution( + localItem: local, + match: local.id == '3' + ? null + : Track( + id: local.id, + name: local.trackName, + artistName: 'Artist', + albumName: 'Album', + duration: 1, + ), + score: 100, + reason: 'fixture', + ); + }, + ); + expect(calls, 4); + expect(result.tracks.map((track) => track.id), ['1', '4']); + expect(result.skipped, 2); + }, + ); + + for (final leaveDuringRefresh in [false, true]) { + testWidgets( + 'batch completion respects owner lifecycle during refresh: $leaveDuringRefresh', + (tester) async { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final settings = _BatchSettings(); + final library = _BatchLibrary(); + var active = true; + final events = []; + Future? action; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + if (call.method != 'reEnrichFile') { + throw StateError('unexpected call'); + } + final request = + jsonDecode((call.arguments as Map)['request_json'] as String) + as Map; + return request['preview_only'] == true + ? { + 'method': 'preview', + 'enriched_metadata': {'isrc': 'USABC2600001'}, + } + : {'method': 'native'}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(() => settings), + localLibraryProvider.overrideWith(() => library), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () { + action = reEnrichLocalTracks( + context, + ref, + [item('1'), item('2')], + isActive: () => active, + onSelectionHide: () async { + events.add('hide'); + }, + onSelectionRestore: () => events.add('restore'), + onComplete: () => events.add('complete'), + ); + }, + child: const Text('Start'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Start')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Review changes')); + await tester.pumpAndSettle(); + expect(settings.phases.length, 1); + await tester.tap(find.text('Apply changes')); + await tester.pumpAndSettle(); + expect(library.refreshStarted, isTrue); + expect(settings.phases.length, 2); + active = !leaveDuringRefresh; + library.refreshed.complete(); + await tester.pumpAndSettle(); + await action; + expect(events, leaveDuringRefresh ? ['hide'] : ['hide', 'complete']); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final overlaySelection in [false, true]) { + testWidgets( + 'cancel restores ${overlaySelection ? 'removed overlay' : 'animated bar'} selection', + (tester) async { + final navigatorKey = GlobalKey(); + final events = []; + OverlayEntry? overlay; + Future? action; + Widget button(BuildContext context, WidgetRef ref) => TextButton( + onPressed: () { + action = reEnrichLocalTracks( + context, + ref, + [item('1')], + isActive: () => true, + onSelectionHide: () async { + events.add('hide'); + if (overlaySelection) { + overlay!.remove(); + } else { + await Future.delayed(const Duration(milliseconds: 300)); + } + }, + onSelectionRestore: () => events.add('restore'), + onComplete: () => events.add('complete'), + ); + }, + child: const Text('Start'), + ); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + navigatorKey: navigatorKey, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + if (!overlaySelection) return button(context, ref); + return TextButton( + onPressed: () { + overlay = OverlayEntry( + builder: (context) => Material( + child: Center(child: button(context, ref)), + ), + ); + Overlay.of(context).insert(overlay!); + }, + child: const Text('Select'), + ); + }, + ), + ), + ), + ), + ); + if (overlaySelection) { + await tester.tap(find.text('Select')); + await tester.pump(); + } + await tester.tap(find.text('Start')); + await tester.pumpAndSettle(); + expect(events, ['hide']); + expect(navigatorKey.currentState!.canPop(), isTrue); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + await action; + expect(events, ['hide', 'restore']); + expect(tester.takeException(), isNull); + overlay?.dispose(); + }, + ); + } +}