diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index da8e37e8..7f2815b5 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -7,9 +7,7 @@ import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; -import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -19,9 +17,6 @@ import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.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'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; @@ -662,68 +657,19 @@ class _DownloadedAlbumScreenState extends ConsumerState } Future _reEnrichSelected(List allTracks) async { - final selected = allTracks - .where((track) => selectedIds.contains(track.id)) - .toList(growable: false); - final history = ref.read(downloadHistoryProvider.notifier); - await reEnrichLocalTracks( + await reEnrichLibraryTracks( context, ref, - [ - for (final track in selected) - LocalLibraryItem.fromJson({ - ...track.toJson(), - 'scannedAt': track.downloadedAt.toIso8601String(), - }), - ], + _selectedUnifiedItems(allTracks), isActive: () => mounted, onSelectionHide: () async { setState(() => isSelectionMode = false); await Future.delayed(const Duration(milliseconds: 300)); }, onSelectionRestore: () => setState(() => isSelectionMode = true), - onComplete: exitSelectionMode, - refreshLibrary: () async { - // Downloaded albums read history, not the local-scan index. Read back - // the saved tags so failed or unselected fields cannot change history. - for (final track in selected) { - try { - await DownloadedEmbeddedCoverResolver.invalidate(track.filePath); - final metadata = await PlatformBridge.readDisplayAudioMetadata( - track.filePath, - ); - if (metadata['error'] != null) continue; - await history.updateMetadataForItem( - id: track.id, - trackName: metadata['title'] as String? ?? track.trackName, - artistName: metadata['artist'] as String? ?? track.artistName, - albumName: metadata['album'] as String? ?? track.albumName, - albumArtist: metadata['album_artist'] as String?, - isrc: metadata['isrc'] as String?, - trackNumber: readPositiveInt(metadata['track_number']), - totalTracks: readPositiveInt(metadata['total_tracks']), - discNumber: readPositiveInt(metadata['disc_number']), - totalDiscs: readPositiveInt(metadata['total_discs']), - releaseDate: metadata['date'] as String?, - genre: metadata['genre'] as String?, - composer: metadata['composer'] as String?, - label: metadata['label'] as String?, - copyright: metadata['copyright'] as String?, - explicit: parseExplicitFlag(metadata['explicit']), - hasLyrics: - metadata.containsKey('hasLyrics') || - metadata.containsKey('lyrics') - ? metadata['hasLyrics'] == true || - hasUsableLyricsContent( - metadata['lyrics']?.toString() ?? '', - ) - : null, - ); - } catch (error) { - debugPrint('Could not refresh re-enriched download: $error'); - } - } + onComplete: () { _onEmbeddedCoverChanged(); + exitSelectionMode(); }, ); } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index c79befa2..ff07c0a1 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -19,7 +19,6 @@ import 'package:spotiflac_android/widgets/library_search_results.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; diff --git a/lib/screens/queue_tab_batch_actions.dart b/lib/screens/queue_tab_batch_actions.dart index b6b1926d..8e260c62 100644 --- a/lib/screens/queue_tab_batch_actions.dart +++ b/lib/screens/queue_tab_batch_actions.dart @@ -21,64 +21,22 @@ extension _QueueTabBatchActions on _QueueTabState { onComplete: _exitSelectionMode, ); - Future _reEnrichSelectedLocalFromQueue( - List allItems, - ) => reEnrichLocalTracks( - context, - ref, - _selectedItemsFromAll(allItems) - .map((item) => item.localItem) - .whereType() - .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 { - final itemsById = {for (final item in allItems) item.id: item}; - final safUris = []; - final filesToShare = []; - - for (final id in _selectedIds) { - final item = itemsById[id]; - if (item == null) continue; - final path = item.filePath; - if (isContentUri(path)) { - if (await fileExists(path)) safUris.add(path); - } else if (await fileExists(path)) { - filesToShare.add(XFile(path)); - } - } - - if (safUris.isEmpty && filesToShare.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.selectionShareNoFiles)), - ); - } - return; - } - - if (safUris.isNotEmpty) { - try { - if (safUris.length == 1) { - await PlatformBridge.shareContentUri(safUris.first); - } else { - await PlatformBridge.shareMultipleContentUris(safUris); - } - } catch (_) {} - } - - if (filesToShare.isNotEmpty) { - await SharePlus.instance.share(ShareParams(files: filesToShare)); - } - } + Future _reEnrichSelectedFromQueue(List allItems) => + reEnrichLibraryTracks( + context, + ref, + _selectedItemsFromAll(allItems), + isActive: () => mounted, + onSelectionHide: () async { + _setState(() => _isSelectionMode = false); + _hideSelectionOverlay(); + }, + onSelectionRestore: () => _setState(() => _isSelectionMode = true), + onComplete: () { + _onEmbeddedCoverChanged(); + _exitSelectionMode(); + }, + ); Future _showBatchConvertSheet( BuildContext context, diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index f9fab858..22afdc1e 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -49,16 +49,10 @@ extension _QueueTabItemWidgets on _QueueTabState { actions.add( SelectionActionButton( - icon: localOnlySelection - ? Icons.auto_fix_high_outlined - : Icons.share_outlined, - label: localOnlySelection - ? '${context.l10n.trackReEnrich} ($selectedCount)' - : context.l10n.selectionShareCount(selectedCount), + icon: Icons.auto_fix_high_outlined, + label: '${context.l10n.trackReEnrich} ($selectedCount)', onPressed: selectedCount > 0 - ? () => localOnlySelection - ? _reEnrichSelectedLocalFromQueue(unifiedItems) - : _shareSelected(unifiedItems) + ? () => _reEnrichSelectedFromQueue(unifiedItems) : null, colorScheme: colorScheme, ), diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart index 3fee5648..8ecfa845 100644 --- a/lib/services/local_track_batch_actions.dart +++ b/lib/services/local_track_batch_actions.dart @@ -5,16 +5,20 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/re_enrich_result.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'; 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/downloaded_embedded_cover_resolver.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/int_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; +import 'package:spotiflac_android/utils/string_utils.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'; @@ -137,6 +141,100 @@ Future queueLocalTracksAsFlac( onComplete(); } +Future reEnrichLibraryTracks( + 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; + final downloads = selected + .map((item) => item.historyItem) + .whereType() + .toList(growable: false); + final history = downloads.isEmpty + ? null + : ref.read(downloadHistoryProvider.notifier); + await reEnrichLocalTracks( + context, + ref, + [ + for (final item in selected) + if (item.localItem != null) + item.localItem! + else if (item.historyItem case final track?) + LocalLibraryItem.fromJson({ + ...track.toJson(), + 'scannedAt': track.downloadedAt.toIso8601String(), + }), + ], + isActive: isActive, + onSelectionHide: onSelectionHide, + onSelectionRestore: onSelectionRestore, + onComplete: onComplete, + refreshLibrary: () async { + // Downloaded tracks read history, not the local-scan index. Read back + // saved tags so failed or unselected fields cannot change history. + for (final track in downloads) { + try { + await DownloadedEmbeddedCoverResolver.invalidate(track.filePath); + final metadata = await PlatformBridge.readDisplayAudioMetadata( + track.filePath, + ); + if (metadata['error'] != null) continue; + await history!.updateMetadataForItem( + id: track.id, + trackName: metadata['title'] as String? ?? track.trackName, + artistName: metadata['artist'] as String? ?? track.artistName, + albumName: metadata['album'] as String? ?? track.albumName, + albumArtist: metadata['album_artist'] as String?, + isrc: metadata['isrc'] as String?, + trackNumber: readPositiveInt(metadata['track_number']), + totalTracks: readPositiveInt(metadata['total_tracks']), + discNumber: readPositiveInt(metadata['disc_number']), + totalDiscs: readPositiveInt(metadata['total_discs']), + releaseDate: metadata['date'] as String?, + genre: metadata['genre'] as String?, + composer: metadata['composer'] as String?, + label: metadata['label'] as String?, + copyright: metadata['copyright'] as String?, + explicit: parseExplicitFlag(metadata['explicit']), + hasLyrics: + metadata.containsKey('hasLyrics') || + metadata.containsKey('lyrics') + ? metadata['hasLyrics'] == true || + hasUsableLyricsContent( + metadata['lyrics']?.toString() ?? '', + ) + : null, + ); + } catch (error) { + debugPrint('Could not refresh re-enriched download: $error'); + } + } + if (isActive() && selected.any((item) => item.localItem != null)) { + await _refreshReEnrichedLocalLibrary(ref); + } + }, + ); +} + +Future _refreshReEnrichedLocalLibrary(WidgetRef ref) async { + final library = ref.read(localLibraryProvider.notifier); + try { + if (!ref.read(localLibraryProvider).isScanning) { + await library.scanAllSources(); + } else { + await library.reloadFromStorage(); + } + } catch (_) { + await library.reloadFromStorage(); + } +} + Future reEnrichLocalTracks( BuildContext context, WidgetRef ref, @@ -247,15 +345,7 @@ Future reEnrichLocalTracks( if (refreshLibrary != null) { await refreshLibrary(); } else { - 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(); - } + await _refreshReEnrichedLocalLibrary(ref); } if (!context.mounted || !isActive()) return; diff --git a/test/downloaded_album_reenrich_test.dart b/test/downloaded_album_reenrich_test.dart index d1056bb5..eb4c7579 100644 --- a/test/downloaded_album_reenrich_test.dart +++ b/test/downloaded_album_reenrich_test.dart @@ -8,10 +8,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; @@ -55,12 +59,153 @@ class _History extends DownloadHistoryNotifier { } } +class _Library extends LocalLibraryNotifier { + int refreshes = 0; + + @override + LocalLibraryState build() => LocalLibraryState(); + + @override + Future scanAllSources({bool forceFullScan = false}) async { + refreshes++; + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); const channel = MethodChannel('com.zarz.spotiflac/backend'); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + for (final includeLocal in [false, true]) { + testWidgets( + 'Library re-enrich includes downloaded tracks and refreshes each source (mixed: $includeLocal)', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final cache = Directory.systemTemp.createTempSync('library_reenrich_'); + DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting( + cache, + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(() async { + await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting(); + DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting( + null, + ); + await cache.delete(recursive: true); + }); + }); + final history = _History(); + final library = _Library(); + final requests = >[]; + final paths = [ + 'content://library/document/download.flac', + if (includeLocal) '/music/local.flac', + ]; + var completed = false; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'reEnrichFile') { + final request = Map.from( + jsonDecode((call.arguments as Map)['request_json'] as String) + as Map, + ); + requests.add(request); + return request['preview_only'] == true + ? { + 'method': 'preview', + 'enriched_metadata': {'isrc': 'USABC2600001'}, + } + : {'method': 'native'}; + } + if (call.method == 'readAudioMetadata') { + return { + 'trackName': 'Downloaded', + 'artistName': 'Artist', + 'albumName': 'Album', + 'isrc': 'USABC2600001', + }; + } + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + downloadHistoryProvider.overrideWith(() => history), + localLibraryProvider.overrideWith(() => library), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => reEnrichLibraryTracks( + context, + ref, + [ + UnifiedLibraryItem.fromDownloadHistory( + DownloadHistoryItem( + id: 'download', + trackName: 'Downloaded', + artistName: 'Artist', + albumName: 'Album', + filePath: paths.first, + service: 'example-provider', + downloadedAt: DateTime(2026), + ), + ), + if (includeLocal) + UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem( + id: 'local', + trackName: 'Local', + artistName: 'Artist', + albumName: 'Album', + filePath: paths.last, + scannedAt: DateTime(2026), + ), + ), + ], + isActive: () => context.mounted, + onSelectionHide: () async {}, + onSelectionRestore: () {}, + onComplete: () => completed = true, + ), + child: const Text('Start'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Start')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Review changes')); + await tester.pumpAndSettle(); + expect(requests.map((request) => request['file_path']), paths); + await tester.tap(find.text('Apply changes')); + for (var attempt = 0; attempt < 50 && !completed; attempt++) { + await tester.pump(const Duration(milliseconds: 100)); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 10)); + }); + } + await tester.pumpAndSettle(); + expect(requests.map((request) => request['file_path']), [ + ...paths, + ...paths, + ]); + expect(history.updates, {'download': 'USABC2600001'}); + expect(library.refreshes, includeLocal ? 1 : 0); + expect(completed, isTrue); + expect(tester.takeException(), isNull); + }, + ); + } + for (final mornye in [false, true]) { testWidgets( 'downloaded album re-enrich restores selection on cancel and updates only selected files ($mornye)',