diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index b7ed3972..da8e37e8 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -6,10 +6,11 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; 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:share_plus/share_plus.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'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; @@ -18,6 +19,9 @@ 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'; @@ -657,44 +661,71 @@ class _DownloadedAlbumScreenState extends ConsumerState ); } - Future _shareSelected(List allTracks) async { - final tracksById = {for (final t in allTracks) t.id: t}; - final safUris = []; - final filesToShare = []; - - for (final id in selectedIds) { - final item = tracksById[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); + 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( + context, + ref, + [ + for (final track in selected) + LocalLibraryItem.fromJson({ + ...track.toJson(), + 'scannedAt': track.downloadedAt.toIso8601String(), + }), + ], + 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'); + } } - } catch (_) {} - } - - if (filesToShare.isNotEmpty) { - await SharePlus.instance.share(ShareParams(files: filesToShare)); - } + _onEmbeddedCoverChanged(); + }, + ); } List _selectedUnifiedItems( @@ -737,10 +768,10 @@ class _DownloadedAlbumScreenState extends ConsumerState (constraints.maxWidth - spacing * (columns - 1)) / columns; final actions = [ SelectionActionButton( - icon: Icons.share_outlined, - label: context.l10n.selectionShareCount(selectedCount), + icon: Icons.auto_fix_high_outlined, + label: '${context.l10n.trackReEnrich} ($selectedCount)', onPressed: selectedCount > 0 - ? () => _shareSelected(tracks) + ? () => _reEnrichSelected(tracks) : null, colorScheme: colorScheme, ), diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart index 2c1ef6cc..3fee5648 100644 --- a/lib/services/local_track_batch_actions.dart +++ b/lib/services/local_track_batch_actions.dart @@ -145,6 +145,7 @@ Future reEnrichLocalTracks( required Future Function() onSelectionHide, required VoidCallback onSelectionRestore, required VoidCallback onComplete, + Future Function()? refreshLibrary, }) async { if (selected.isEmpty) return; // Capture a stable route context before a caller removes its overlay. @@ -243,14 +244,18 @@ Future reEnrichLocalTracks( if (!context.mounted || !isActive()) return; if (!cancelled) BatchProgressDialog.dismiss(context); - try { - if (!ref.read(localLibraryProvider).isScanning) { - await ref.read(localLibraryProvider.notifier).scanAllSources(); - } else { + 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(); } - } catch (_) { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } if (!context.mounted || !isActive()) return; diff --git a/test/downloaded_album_reenrich_test.dart b/test/downloaded_album_reenrich_test.dart new file mode 100644 index 00000000..d1056bb5 --- /dev/null +++ b/test/downloaded_album_reenrich_test.dart @@ -0,0 +1,209 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +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/providers/download_history_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/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); + + @override + Future syncLyricsSettingsToBackend({AppSettings? settings}) async {} +} + +class _History extends DownloadHistoryNotifier { + final updates = {}; + + @override + DownloadHistoryState build() => DownloadHistoryState(); + + @override + Future updateMetadataForItem({ + required String id, + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? isrc, + int? trackNumber, + int? totalTracks, + int? discNumber, + int? totalDiscs, + String? releaseDate, + String? genre, + String? composer, + String? label, + String? copyright, + bool? explicit, + bool? hasLyrics, + int? lyricsMetadataScanVersion, + }) async { + updates[id] = isrc; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + for (final mornye in [false, true]) { + testWidgets( + 'downloaded album re-enrich restores selection on cancel and updates only selected files ($mornye)', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final cache = Directory.systemTemp.createTempSync('album_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); + }); + }); + const path = 'content://library/document/track.flac'; + final requests = >[]; + 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': 'Track', + 'artistName': 'Artist', + 'albumName': 'Album', + 'isrc': 'USABC2600001', + 'hasLyrics': true, + }; + } + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final history = _History(); + final navigator = GlobalKey(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + downloadHistoryProvider.overrideWith(() => history), + downloadedAlbumTracksProvider( + const DownloadedAlbumTracksRequest( + albumName: 'Album', + artistName: 'Artist', + ), + ).overrideWith( + (ref) async => [ + for (final id in ['track', 'other']) + DownloadHistoryItem( + id: id, + trackName: id == 'track' ? 'Track' : 'Other', + artistName: 'Artist', + albumName: 'Album', + filePath: id == 'track' ? path : '/music/other.flac', + service: 'example-provider', + downloadedAt: DateTime(2026), + duration: 180, + ), + ], + ), + ], + child: MaterialApp( + navigatorKey: navigator, + theme: mornye + ? MornyeTheme.build(Brightness.light) + : AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const SelectionOverlayHost( + child: DownloadedAlbumScreen( + albumName: 'Album', + artistName: 'Artist', + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Track'), 200); + await tester.longPress(find.text('Track')); + await tester.pumpAndSettle(); + final l10n = AppLocalizations.of( + tester.element(find.byType(SelectionBottomBar)), + ); + expect(find.text(l10n.selectionShareCount(1)), findsNothing); + + Future openReEnrich() async { + await tester.tap(find.text('${l10n.trackReEnrich} (1)')); + await tester.pumpAndSettle(); + expect(find.byType(SelectionBottomBar), findsNothing); + expect(find.text('Review changes'), findsOneWidget); + } + + await openReEnrich(); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(find.byType(SelectionBottomBar), findsOneWidget); + expect(requests, isEmpty); + + await openReEnrich(); + await tester.ensureVisible(find.text('Review changes')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Review changes')); + await tester.pumpAndSettle(); + expect(requests, hasLength(1)); + expect(requests.single['file_path'], path); + expect(requests.single['duration_ms'], 180000); + await tester.ensureVisible(find.text('Apply changes')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply changes')); + // The history refresh also invalidates the disk-backed cover cache. + for ( + var attempt = 0; + attempt < 50 && history.updates.isEmpty; + attempt++ + ) { + await tester.pump(const Duration(milliseconds: 100)); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 10)); + }); + } + await tester.pumpAndSettle(); + expect(requests, hasLength(2)); + expect(requests.last['file_path'], path); + expect(history.updates, {'track': 'USABC2600001'}); + expect(find.byType(SelectionBottomBar), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } +}