From f67f52eba9c5dc510c33461d420de6f2a82e9b64 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Mar 2026 21:35:25 +0000 Subject: [PATCH 01/34] chore: update AltStore source to v3.8.5 --- apps.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps.json b/apps.json index 2e4c6718..81613e65 100644 --- a/apps.json +++ b/apps.json @@ -7,12 +7,12 @@ "name": "SpotiFLAC", "bundleIdentifier": "com.zarzet.spotiflac", "developerName": "zarzet", - "version": "3.8.0", - "versionDate": "2026-03-14", - "downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.0/SpotiFLAC-v3.8.0-ios-unsigned.ipa", + "version": "3.8.5", + "versionDate": "2026-03-15", + "downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.5/SpotiFLAC-v3.8.5-ios-unsigned.ipa", "localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.", "iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png", - "size": 33659289 + "size": 33673615 } ] } From 387dd47374a971712f281272a55571f3699fc9ce Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 15 Mar 2026 18:52:41 +0700 Subject: [PATCH 02/34] feat: add Qobuz Afkar API provider and prefer request metadata for consistent album grouping --- go_backend/exports.go | 59 +++++++++++++++++--------- go_backend/exports_test.go | 86 ++++++++++++++++++++++++++++++++++++++ go_backend/qobuz.go | 29 ++++++++++--- go_backend/qobuz_test.go | 6 ++- go_backend/tidal.go | 16 +++++-- pubspec.lock | 32 ++++++-------- 6 files changed, 178 insertions(+), 50 deletions(-) create mode 100644 go_backend/exports_test.go diff --git a/go_backend/exports.go b/go_backend/exports.go index 5a8a7840..b8178d77 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -135,6 +135,36 @@ type DownloadResult struct { DecryptionKey string } +func preferredReleaseMetadata( + req DownloadRequest, + album string, + releaseDate string, + trackNumber int, + discNumber int, +) (string, string, int, int) { + preferredAlbum := strings.TrimSpace(req.AlbumName) + if preferredAlbum == "" { + preferredAlbum = album + } + + preferredReleaseDate := strings.TrimSpace(req.ReleaseDate) + if preferredReleaseDate == "" { + preferredReleaseDate = releaseDate + } + + preferredTrackNumber := req.TrackNumber + if preferredTrackNumber == 0 { + preferredTrackNumber = trackNumber + } + + preferredDiscNumber := req.DiscNumber + if preferredDiscNumber == 0 { + preferredDiscNumber = discNumber + } + + return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber +} + func buildDownloadSuccessResponse( req DownloadRequest, result DownloadResult, @@ -153,25 +183,16 @@ func buildDownloadSuccessResponse( artist = req.ArtistName } - album := result.Album - if album == "" { - album = req.AlbumName - } - - releaseDate := result.ReleaseDate - if releaseDate == "" { - releaseDate = req.ReleaseDate - } - - trackNumber := result.TrackNumber - if trackNumber == 0 { - trackNumber = req.TrackNumber - } - - discNumber := result.DiscNumber - if discNumber == 0 { - discNumber = req.DiscNumber - } + // Preserve requested release metadata when available so mixed-provider + // fallback downloads from the same source album do not get split into + // different albums just because Tidal/Qobuz report variant titles/dates. + album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata( + req, + result.Album, + result.ReleaseDate, + result.TrackNumber, + result.DiscNumber, + ) isrc := result.ISRC if isrc == "" { diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go new file mode 100644 index 00000000..dbce4278 --- /dev/null +++ b/go_backend/exports_test.go @@ -0,0 +1,86 @@ +package gobackend + +import "testing" + +func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) { + req := DownloadRequest{ + TrackName: "Bonus Track", + ArtistName: "Artist", + AlbumName: "Album (Deluxe)", + AlbumArtist: "Artist", + ReleaseDate: "2024-01-01", + TrackNumber: 14, + DiscNumber: 1, + ISRC: "REQ123", + CoverURL: "https://example.com/cover.jpg", + Genre: "Pop", + Label: "Label", + Copyright: "Copyright", + } + + result := DownloadResult{ + Title: "Bonus Track", + Artist: "Artist", + Album: "Album", + ReleaseDate: "2023-12-01", + TrackNumber: 2, + DiscNumber: 9, + ISRC: "RES456", + } + + resp := buildDownloadSuccessResponse( + req, + result, + "tidal", + "ok", + "/tmp/test.flac", + false, + ) + + if resp.Album != req.AlbumName { + t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName) + } + if resp.ReleaseDate != req.ReleaseDate { + t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate) + } + if resp.TrackNumber != req.TrackNumber { + t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber) + } + if resp.DiscNumber != req.DiscNumber { + t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber) + } + if resp.Artist != result.Artist { + t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist) + } + if resp.ISRC != result.ISRC { + t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC) + } +} + +func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) { + album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata( + DownloadRequest{ + AlbumName: "Album (Deluxe Edition)", + ReleaseDate: "2024-01-01", + TrackNumber: 13, + DiscNumber: 2, + }, + "Album", + "2023-01-01", + 3, + 1, + ) + + if album != "Album (Deluxe Edition)" { + t.Fatalf("album = %q", album) + } + if releaseDate != "2024-01-01" { + t.Fatalf("release date = %q", releaseDate) + } + if trackNumber != 13 { + t.Fatalf("track number = %d", trackNumber) + } + if discNumber != 2 { + t.Fatalf("disc number = %d", discNumber) + } +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 788f816f..ec0489ff 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -54,6 +54,7 @@ const ( qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" + qobuzAfkarAPIURL = "https://qbz.afkarxyz.fun/api/track/" qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id=" qobuzDebugKeyXORMask = byte(0x5A) ) @@ -1019,6 +1020,10 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP func (q *QobuzDownloader) GetAvailableAPIs() []string { return []string{ qobuzDownloadAPIURL, + qobuzDabMusicAPIURL, + qobuzDeebAPIURL, + qobuzAfkarAPIURL, + qobuzSquidAPIURL, } } @@ -1039,6 +1044,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, // "deeb" is mapped from the legacy reference fallback endpoint. {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, + // "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=... + {Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard}, {Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard}, } } @@ -2156,6 +2163,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { if req.AlbumName != "" { albumName = req.AlbumName } + releaseDate := track.Album.ReleaseDate + if req.ReleaseDate != "" { + releaseDate = req.ReleaseDate + } actualTrackNumber := req.TrackNumber if actualTrackNumber == 0 { @@ -2167,7 +2178,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { Artist: track.Performer.Name, Album: albumName, AlbumArtist: req.AlbumArtist, - Date: track.Album.ReleaseDate, + Date: releaseDate, TrackNumber: actualTrackNumber, TotalTracks: req.TotalTracks, DiscNumber: req.DiscNumber, @@ -2231,16 +2242,24 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { lyricsLRC = parallelResult.LyricsLRC } + resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata( + req, + track.Album.Title, + track.Album.ReleaseDate, + actualTrackNumber, + req.DiscNumber, + ) + return QobuzDownloadResult{ FilePath: outputPath, BitDepth: actualBitDepth, SampleRate: actualSampleRate, Title: track.Title, Artist: track.Performer.Name, - Album: track.Album.Title, - ReleaseDate: track.Album.ReleaseDate, - TrackNumber: actualTrackNumber, - DiscNumber: req.DiscNumber, + Album: resultAlbum, + ReleaseDate: resultReleaseDate, + TrackNumber: resultTrackNumber, + DiscNumber: resultDiscNumber, ISRC: track.ISRC, LyricsLRC: lyricsLRC, }, nil diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index d19629b6..9d15d318 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -213,14 +213,16 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) { func TestQobuzAvailableProviders(t *testing.T) { providers := NewQobuzDownloader().GetAvailableProviders() - if len(providers) != 3 { - t.Fatalf("expected 3 Qobuz providers, got %d", len(providers)) + if len(providers) != 5 { + t.Fatalf("expected 5 Qobuz providers, got %d", len(providers)) } want := map[string]string{ "musicdl": qobuzAPIKindMusicDL, "dabmusic": qobuzAPIKindStandard, "deeb": qobuzAPIKindStandard, + "qbz": qobuzAPIKindStandard, + "squid": qobuzAPIKindStandard, } for _, provider := range providers { diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 8a68826d..a837380d 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -2189,16 +2189,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { lyricsLRC = parallelResult.LyricsLRC } + resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata( + req, + track.Album.Title, + track.Album.ReleaseDate, + actualTrackNumber, + actualDiscNumber, + ) + return TidalDownloadResult{ FilePath: actualOutputPath, BitDepth: bitDepth, SampleRate: sampleRate, Title: track.Title, Artist: track.Artist.Name, - Album: track.Album.Title, - ReleaseDate: track.Album.ReleaseDate, - TrackNumber: actualTrackNumber, - DiscNumber: actualDiscNumber, + Album: resultAlbum, + ReleaseDate: resultReleaseDate, + TrackNumber: resultTrackNumber, + DiscNumber: resultDiscNumber, ISRC: track.ISRC, LyricsLRC: lyricsLRC, }, nil diff --git a/pubspec.lock b/pubspec.lock index 4fc7941f..0370159a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -557,14 +557,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -633,18 +625,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1166,26 +1158,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.16" timezone: dependency: transitive description: From 16100aa0fdd980994cffde82fe208bb0db97e3b8 Mon Sep 17 00:00:00 2001 From: ViscousPot Date: Sat, 14 Mar 2026 01:39:16 +0000 Subject: [PATCH 03/34] add fvm --- .fvmrc | 3 +++ .gitignore | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 .fvmrc diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..5c08b3bc --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.38.1" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 699e248c..517be8ed 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,6 @@ flutter_*.log # Development tools tool/ .claude/settings.local.json + +# FVM Version Cache +.fvm/ \ No newline at end of file From a6d9849468baa5c21b01b2d754aa118fc24412d0 Mon Sep 17 00:00:00 2001 From: ViscousPot Date: Sun, 15 Mar 2026 04:37:00 +0000 Subject: [PATCH 04/34] Update CONTRIBUTING.md --- CONTRIBUTING.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1750732..2c1e7b6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`. git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git ``` -3. **Install dependencies** +3. **Use FVM (Flutter Version: 3.38.1)** + ```bash + fvm use + ``` + +4. **Install dependencies** ```bash flutter pub get ``` -4. **Generate code** (for Riverpod, JSON serialization, etc.) +5. **Generate code** (for Riverpod, JSON serialization, etc.) ```bash dart run build_runner build --delete-conflicting-outputs ``` -5. **Run the app** +6. **Set up Go environment (Go Version: 1.25.7)** + ```bash + cd go_backend + mkdir -p ../android/app/libs + gomobile init + gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar . + cd .. + ``` + +7. **Run the app** ```bash flutter run ``` From 49ea84384d4f05889873ab329704c6d624fabb75 Mon Sep 17 00:00:00 2001 From: ViscousPot Date: Sat, 14 Mar 2026 02:08:49 +0000 Subject: [PATCH 05/34] feat: auto fill playlist name during import --- lib/screens/playlist_screen.dart | 2 +- lib/widgets/playlist_picker_sheet.dart | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index ba16a01f..8a035def 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -533,7 +533,7 @@ class _PlaylistScreenState extends ConsumerState { tooltip: context.l10n.tooltipAddToPlaylist, onPressed: _tracks.isEmpty ? null - : () => showAddTracksToPlaylistSheet(context, ref, _tracks), + : () => showAddTracksToPlaylistSheet(context, ref, _tracks, playlistNamePrefill: widget.playlistName), ); } diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index 2b6fd162..238b5350 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -20,6 +20,7 @@ Future showAddTracksToPlaylistSheet( BuildContext context, WidgetRef ref, List tracks, + {String? playlistNamePrefill} ) async { if (tracks.isEmpty) return; @@ -31,15 +32,16 @@ Future showAddTracksToPlaylistSheet( showDragHandle: true, isScrollControlled: true, builder: (sheetContext) { - return _PlaylistPickerSheetContent(tracks: tracks); + return _PlaylistPickerSheetContent(tracks: tracks, playlistNamePrefill: playlistNamePrefill); }, ); } class _PlaylistPickerSheetContent extends ConsumerStatefulWidget { final List tracks; + final String? playlistNamePrefill; - const _PlaylistPickerSheetContent({required this.tracks}); + const _PlaylistPickerSheetContent({required this.tracks, this.playlistNamePrefill}); @override ConsumerState<_PlaylistPickerSheetContent> createState() => @@ -130,7 +132,7 @@ class _PlaylistPickerSheetContentState leading: const Icon(Icons.add_circle_outline), title: Text(context.l10n.collectionCreatePlaylist), onTap: () async { - final name = await _promptPlaylistName(context); + final name = await _promptPlaylistName(context, widget.playlistNamePrefill); if (name == null || name.trim().isEmpty || !context.mounted) { return; } @@ -221,8 +223,8 @@ class _PlaylistPickerSheetContentState } } -Future _promptPlaylistName(BuildContext context) async { - final controller = TextEditingController(); +Future _promptPlaylistName(BuildContext context, String? playlistNamePrefill) async { + final controller = TextEditingController(text: playlistNamePrefill); final formKey = GlobalKey(); final result = await showDialog( From d4178ad0369935d7fbc750f1035ba17506f8bbd5 Mon Sep 17 00:00:00 2001 From: ViscousPot Date: Sat, 14 Mar 2026 01:38:32 +0000 Subject: [PATCH 06/34] feat: add option to download multiple selected playlists --- lib/screens/queue_tab.dart | 117 +++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0a56f7a1..7e3f96f9 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -31,6 +31,7 @@ import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; enum LibraryItemSource { downloaded, local } @@ -1314,6 +1315,93 @@ class _QueueTabState extends ConsumerState { }); } + Future _downloadAllSelectedPlaylists(BuildContext context) async { + final collectionsState = ref.read(libraryCollectionsProvider); + final selectedPlaylists = collectionsState.playlists + .where((p) => _selectedPlaylistIds.contains(p.id)) + .toList(); + + final totalTracks = + selectedPlaylists.fold(0, (sum, p) => sum + p.tracks.length); + + if (totalTracks == 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Selected playlists have no tracks')), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Download All'), + content: Text( + 'Download $totalTracks ${totalTracks == 1 ? 'track' : 'tracks'} ' + 'from ${selectedPlaylists.length} ' + '${selectedPlaylists.length == 1 ? 'playlist' : 'playlists'}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Download'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + final settings = ref.read(settingsProvider); + final queueNotifier = ref.read(downloadQueueProvider.notifier); + + void enqueueAll({String? qualityOverride, String? service}) { + final svc = service ?? settings.defaultService; + for (final playlist in selectedPlaylists) { + final tracks = playlist.tracks.map((e) => e.track).toList(); + queueNotifier.addMultipleToQueue( + tracks, + svc, + qualityOverride: qualityOverride, + playlistName: playlist.name, + ); + } + } + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: '$totalTracks tracks', + artistName: '${selectedPlaylists.length} playlists', + onSelect: (quality, service) { + enqueueAll(qualityOverride: quality, service: service); + if (!mounted) return; + _exitPlaylistSelectionMode(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(totalTracks), + ), + ), + ); + }, + ); + } else { + enqueueAll(); + _exitPlaylistSelectionMode(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(totalTracks), + ), + ), + ); + } + } + Future _deleteSelectedPlaylists(BuildContext context) async { final count = _selectedPlaylistIds.length; final confirmed = await showDialog( @@ -1452,6 +1540,35 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _downloadAllSelectedPlaylists(context) + : null, + icon: const Icon(Icons.download_rounded), + label: Text( + selectedCount > 0 + ? 'Download $selectedCount ${selectedCount == 1 ? 'playlist' : 'playlists'}' + : 'Select playlists to download', + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.primary + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + + const SizedBox(height: 8), + SizedBox( width: double.infinity, child: FilledButton.icon( From 12779778d39c5fce85986050d12ca3ccd8005801 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 15 Mar 2026 19:01:45 +0700 Subject: [PATCH 07/34] fix(i18n): localize hardcoded strings in bulk playlist download and fix trailing newlines --- .fvmrc | 2 +- .gitignore | 2 +- lib/l10n/app_localizations.dart | 44 +++++++++++++++++++++---- lib/l10n/app_localizations_de.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_en.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_es.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_fr.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_hi.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_id.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_ja.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_ko.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_nl.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_pt.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_ru.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_tr.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/app_localizations_zh.dart | 52 ++++++++++++++++++++++++++++-- lib/l10n/arb/app_en.arb | 46 ++++++++++++++++++++++++++ lib/screens/queue_tab.dart | 18 +++++------ 18 files changed, 730 insertions(+), 58 deletions(-) diff --git a/.fvmrc b/.fvmrc index 5c08b3bc..3a62807c 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { "flutter": "3.38.1" -} \ No newline at end of file +} diff --git a/.gitignore b/.gitignore index 517be8ed..b24e9371 100644 --- a/.gitignore +++ b/.gitignore @@ -79,4 +79,4 @@ tool/ .claude/settings.local.json # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 53bcba7f..ce8174e2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1066,6 +1066,12 @@ abstract class AppLocalizations { /// **'Import'** String get dialogImport; + /// Confirm button in Download All dialog + /// + /// In en, this message translates to: + /// **'Download'** + String get dialogDownload; + /// Dialog button - discard changes /// /// In en, this message translates to: @@ -4434,7 +4440,7 @@ abstract class AppLocalizations { /// **'Added {count} tracks to Loved'** String snackbarAddedTracksToLoved(int count); - /// Title of the Download All confirmation dialog + /// Dialog title for bulk download confirmation /// /// In en, this message translates to: /// **'Download All'** @@ -4446,12 +4452,6 @@ abstract class AppLocalizations { /// **'Download {count} tracks?'** String dialogDownloadAllMessage(int count); - /// Confirm button in Download All dialog - /// - /// In en, this message translates to: - /// **'Download'** - String get dialogDownload; - /// Checkbox label in import dialog to skip already-downloaded songs /// /// In en, this message translates to: @@ -4817,6 +4817,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Refresh'** String get cacheRefresh; + + /// Dialog message for bulk playlist download confirmation + /// + /// In en, this message translates to: + /// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'** + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount); + + /// Button label for bulk downloading selected playlists + /// + /// In en, this message translates to: + /// **'Download {count} {count, plural, =1{playlist} other{playlists}}'** + String bulkDownloadPlaylistsButton(int count); + + /// Button label when no playlists are selected for download + /// + /// In en, this message translates to: + /// **'Select playlists to download'** + String get bulkDownloadSelectPlaylists; + + /// Snackbar when selected playlists contain no tracks + /// + /// In en, this message translates to: + /// **'Selected playlists have no tracks'** + String get snackbarSelectedPlaylistsEmpty; + + /// Playlist count display + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 playlist} other{{count} playlists}}'** + String playlistsCount(int count); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a3d43076..b913d844 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dialogImport => 'Importieren'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Verwerfen'; @@ -2580,9 +2583,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2790,4 +2790,50 @@ class AppLocalizationsDe extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 03adf67d..07e3ca2d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -2552,9 +2555,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2762,4 +2762,50 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c953dbc8..8a5a974b 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -525,6 +525,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -2552,9 +2555,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2762,6 +2762,52 @@ class AppLocalizationsEs extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 4baf9cba..68d677e1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -2554,9 +2557,6 @@ class AppLocalizationsFr extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2764,4 +2764,50 @@ class AppLocalizationsFr extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 46d6572a..5da28983 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -2552,9 +2555,6 @@ class AppLocalizationsHi extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2762,4 +2762,50 @@ class AppLocalizationsHi extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 6aa97510..7fc8d1de 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get dialogImport => 'Impor'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Buang'; @@ -2559,9 +2562,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2769,4 +2769,50 @@ class AppLocalizationsId extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 92184e0b..04a7a0c2 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get dialogImport => 'インポート'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => '破棄'; @@ -2539,9 +2542,6 @@ class AppLocalizationsJa extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2749,4 +2749,50 @@ class AppLocalizationsJa extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index aed486d5..80e8a1df 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -510,6 +510,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get dialogImport => '불러오기'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => '취소'; @@ -2532,9 +2535,6 @@ class AppLocalizationsKo extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2742,4 +2742,50 @@ class AppLocalizationsKo extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 87c5385a..af603f60 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -2552,9 +2555,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2762,4 +2762,50 @@ class AppLocalizationsNl extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index d45df4f9..fc40c186 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -525,6 +525,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -2552,9 +2555,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2762,6 +2762,52 @@ class AppLocalizationsPt extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3e9d7e73..7a542295 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get dialogImport => 'Импорт'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Отменить'; @@ -2611,9 +2614,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2821,4 +2821,50 @@ class AppLocalizationsRu extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 48a9a7d2..1e5727bf 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -530,6 +530,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get dialogImport => 'İçe aktar'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Vazgeç'; @@ -2564,9 +2567,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2774,4 +2774,50 @@ class AppLocalizationsTr extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 2287f76a..68381882 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -525,6 +525,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get dialogImport => 'Import'; + @override + String get dialogDownload => 'Download'; + @override String get dialogDiscard => 'Discard'; @@ -2552,9 +2555,6 @@ class AppLocalizationsZh extends AppLocalizations { return 'Download $count tracks?'; } - @override - String get dialogDownload => 'Download'; - @override String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs'; @@ -2762,6 +2762,52 @@ class AppLocalizationsZh extends AppLocalizations { @override String get cacheRefresh => 'Refresh'; + + @override + String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) { + String _temp0 = intl.Intl.pluralLogic( + trackCount, + locale: localeName, + other: 'tracks', + one: 'track', + ); + String _temp1 = intl.Intl.pluralLogic( + playlistCount, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $trackCount $_temp0 from $playlistCount $_temp1?'; + } + + @override + String bulkDownloadPlaylistsButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'playlists', + one: 'playlist', + ); + return 'Download $count $_temp0'; + } + + @override + String get bulkDownloadSelectPlaylists => 'Select playlists to download'; + + @override + String get snackbarSelectedPlaylistsEmpty => + 'Selected playlists have no tracks'; + + @override + String playlistsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count playlists', + one: '1 playlist', + ); + return '$_temp0'; + } } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 10bf4938..bc3f2e13 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -671,6 +671,10 @@ "@dialogImport": { "description": "Dialog button - import data" }, + "dialogDownload": "Download", + "@dialogDownload": { + "description": "Dialog button - download action" + }, "dialogDiscard": "Discard", "@dialogDiscard": { "description": "Dialog button - discard changes" @@ -3657,5 +3661,47 @@ "cacheRefresh": "Refresh", "@cacheRefresh": { "description": "Tooltip for refresh button on cache management page" + }, + "dialogDownloadAllTitle": "Download All", + "@dialogDownloadAllTitle": { + "description": "Dialog title for bulk download confirmation" + }, + "dialogDownloadPlaylistsMessage": "Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?", + "@dialogDownloadPlaylistsMessage": { + "description": "Dialog message for bulk playlist download confirmation", + "placeholders": { + "trackCount": { + "type": "int" + }, + "playlistCount": { + "type": "int" + } + } + }, + "bulkDownloadPlaylistsButton": "Download {count} {count, plural, =1{playlist} other{playlists}}", + "@bulkDownloadPlaylistsButton": { + "description": "Button label for bulk downloading selected playlists", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bulkDownloadSelectPlaylists": "Select playlists to download", + "@bulkDownloadSelectPlaylists": { + "description": "Button label when no playlists are selected for download" + }, + "snackbarSelectedPlaylistsEmpty": "Selected playlists have no tracks", + "@snackbarSelectedPlaylistsEmpty": { + "description": "Snackbar when selected playlists contain no tracks" + }, + "playlistsCount": "{count, plural, =1{1 playlist} other{{count} playlists}}", + "@playlistsCount": { + "description": "Playlist count display", + "placeholders": { + "count": { + "type": "int" + } + } } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 7e3f96f9..5b14ff21 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1326,7 +1326,7 @@ class _QueueTabState extends ConsumerState { if (totalTracks == 0) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Selected playlists have no tracks')), + SnackBar(content: Text(context.l10n.snackbarSelectedPlaylistsEmpty)), ); return; } @@ -1334,11 +1334,9 @@ class _QueueTabState extends ConsumerState { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Download All'), + title: Text(ctx.l10n.dialogDownloadAllTitle), content: Text( - 'Download $totalTracks ${totalTracks == 1 ? 'track' : 'tracks'} ' - 'from ${selectedPlaylists.length} ' - '${selectedPlaylists.length == 1 ? 'playlist' : 'playlists'}?', + ctx.l10n.dialogDownloadPlaylistsMessage(totalTracks, selectedPlaylists.length), ), actions: [ TextButton( @@ -1347,7 +1345,7 @@ class _QueueTabState extends ConsumerState { ), FilledButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('Download'), + child: Text(ctx.l10n.dialogDownload), ), ], ), @@ -1374,8 +1372,8 @@ class _QueueTabState extends ConsumerState { if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '$totalTracks tracks', - artistName: '${selectedPlaylists.length} playlists', + trackName: context.l10n.tracksCount(totalTracks), + artistName: context.l10n.playlistsCount(selectedPlaylists.length), onSelect: (quality, service) { enqueueAll(qualityOverride: quality, service: service); if (!mounted) return; @@ -1549,8 +1547,8 @@ class _QueueTabState extends ConsumerState { icon: const Icon(Icons.download_rounded), label: Text( selectedCount > 0 - ? 'Download $selectedCount ${selectedCount == 1 ? 'playlist' : 'playlists'}' - : 'Select playlists to download', + ? context.l10n.bulkDownloadPlaylistsButton(selectedCount) + : context.l10n.bulkDownloadSelectPlaylists, ), style: FilledButton.styleFrom( backgroundColor: selectedCount > 0 From 13845eea04bd56ee6cddd2acbcd4fb773624b6b1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:02:29 +0000 Subject: [PATCH 08/34] chore(deps): update dependency flutter to v3.41.4 --- .fvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.fvmrc b/.fvmrc index 3a62807c..c8437d38 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.38.1" + "flutter": "3.41.4" } From 67737467e0e9085620e2816619422d356cf0f41c Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 15 Mar 2026 19:11:29 +0700 Subject: [PATCH 09/34] ci: auto-update AltStore source (apps.json) on release --- .github/workflows/release.yml | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57772b30..b45e04e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -393,6 +393,63 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + update-altstore: + runs-on: ubuntu-latest + needs: [get-version, build-ios, create-release] + if: ${{ needs.get-version.outputs.is_prerelease != 'true' }} + permissions: + contents: write + + steps: + - name: Checkout main branch + uses: actions/checkout@v6 + with: + ref: main + + - name: Download iOS IPA + uses: actions/download-artifact@v7 + with: + name: ios-ipa + path: ./release + + - name: Update apps.json + run: | + VERSION="${{ needs.get-version.outputs.version }}" + VERSION_NUM="${VERSION#v}" + DATE=$(date -u +%Y-%m-%d) + IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1) + + if [ -z "$IPA_FILE" ]; then + echo "WARNING: IPA file not found, skipping apps.json update" + exit 0 + fi + + IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE") + + if [ ! -f apps.json ]; then + echo "WARNING: apps.json not found on main, skipping" + exit 0 + fi + + jq --arg ver "$VERSION_NUM" \ + --arg date "$DATE" \ + --arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \ + --argjson size "$IPA_SIZE" \ + '.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \ + apps.json > apps.json.tmp && mv apps.json.tmp apps.json + + echo "Updated apps.json:" + cat apps.json + + - name: Commit and push + run: | + VERSION="${{ needs.get-version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add apps.json + git diff --cached --quiet && echo "No changes to commit" || \ + (git commit -m "chore: update AltStore source to ${VERSION}" && git push) + notify-telegram: runs-on: ubuntu-latest needs: [get-version, create-release] From 4495d4bf4e34c334549361c2e7f7f9833eea92a0 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 15 Mar 2026 20:16:44 +0700 Subject: [PATCH 10/34] feat: add Opus 320kbps quality, remove Tidal HIGH tier - Add YouTubeQualityOpus320 constant and opus_320 parser case in Go backend - Expand opus supported bitrates to [128, 256, 320] across Go, Dart settings, and UI - Update default YouTube Opus option from 256 to 320kbps - Remove Tidal HIGH (lossy 320kbps) quality from Go backend, settings model, settings provider, download queue provider (both SAF and non-SAF paths), settings UI (quality option, format picker, helper methods), and l10n keys - Add settings migration v6: auto-migrate users with audioQuality=HIGH to LOSSLESS - Update and add Go test cases for opus_320 and adjusted max bitrate - Regenerate l10n files, remove 10 unused downloadLossy* l10n keys --- go_backend/tidal.go | 40 +- go_backend/youtube.go | 5 +- go_backend/youtube_quality_test.go | 17 +- lib/l10n/app_localizations.dart | 90 ++--- lib/l10n/app_localizations_de.dart | 54 +-- lib/l10n/app_localizations_en.dart | 54 +-- lib/l10n/app_localizations_es.dart | 54 +-- lib/l10n/app_localizations_fr.dart | 54 +-- lib/l10n/app_localizations_hi.dart | 54 +-- lib/l10n/app_localizations_id.dart | 54 +-- lib/l10n/app_localizations_ja.dart | 54 +-- lib/l10n/app_localizations_ko.dart | 54 +-- lib/l10n/app_localizations_nl.dart | 54 +-- lib/l10n/app_localizations_pt.dart | 54 +-- lib/l10n/app_localizations_ru.dart | 54 +-- lib/l10n/app_localizations_tr.dart | 54 +-- lib/l10n/app_localizations_zh.dart | 54 +-- lib/l10n/arb/app_en.arb | 83 ++-- lib/models/settings.dart | 7 +- lib/models/settings.g.dart | 2 - lib/providers/download_queue_provider.dart | 381 +++++------------- lib/providers/settings_provider.dart | 30 +- .../settings/download_settings_page.dart | 132 +----- lib/widgets/download_service_picker.dart | 10 +- 24 files changed, 513 insertions(+), 986 deletions(-) diff --git a/go_backend/tidal.go b/go_backend/tidal.go index a837380d..582e4ff6 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1961,11 +1961,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { outputExt := strings.TrimSpace(req.OutputExt) if outputExt == "" { - if quality == "HIGH" { - outputExt = ".m4a" - } else { - outputExt = ".flac" - } + outputExt = ".flac" } else if !strings.HasPrefix(outputExt, ".") { outputExt = "." + outputExt } @@ -1979,7 +1975,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } m4aPath = outputPath } else { - if outputExt == ".m4a" || quality == "HIGH" { + if outputExt == ".m4a" { filename = sanitizeFilename(filename) + ".m4a" outputPath = filepath.Join(req.OutputDir, filename) m4aPath = outputPath @@ -1992,10 +1988,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil } - if quality != "HIGH" { - if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { - return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil - } + if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { + return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil } } @@ -2151,27 +2145,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Println("[Tidal] No lyrics available from parallel fetch") } } else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) { - if quality == "HIGH" { - GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n") - - if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" - } - - if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { - GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode) - if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Tidal] LRC file saved: %s\n", lrcPath) - } - } - } - } else { - fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") - } + fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") } if !isSafOutput { @@ -2181,10 +2155,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { bitDepth := downloadInfo.BitDepth sampleRate := downloadInfo.SampleRate lyricsLRC := "" - if quality == "HIGH" { - bitDepth = 0 - sampleRate = 44100 - } if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC } diff --git a/go_backend/youtube.go b/go_backend/youtube.go index 58e5d751..3bb65f5a 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -29,6 +29,7 @@ var ( type YouTubeQuality string const ( + YouTubeQualityOpus320 YouTubeQuality = "opus_320" YouTubeQualityOpus256 YouTubeQuality = "opus_256" YouTubeQualityOpus128 YouTubeQuality = "opus_128" YouTubeQualityMP3128 YouTubeQuality = "mp3_128" @@ -37,7 +38,7 @@ const ( ) var ( - youtubeOpusSupportedBitrates = []int{128, 256} + youtubeOpusSupportedBitrates = []int{128, 256, 320} youtubeMp3SupportedBitrates = []int{128, 256, 320} ) @@ -146,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize switch normalizedRaw { case "opus_256", "opus256", "opus": return "opus", 256, YouTubeQualityOpus256 + case "opus_320", "opus320": + return "opus", 320, YouTubeQualityOpus320 case "opus_128", "opus128": return "opus", 128, YouTubeQualityOpus128 case "mp3_320", "mp3320", "mp3", "": diff --git a/go_backend/youtube_quality_test.go b/go_backend/youtube_quality_test.go index cb77e5bb..e0f2ebbf 100644 --- a/go_backend/youtube_quality_test.go +++ b/go_backend/youtube_quality_test.go @@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) { _, opusBitrate, _ := parseYouTubeQualityInput("opus_999") - if opusBitrate != 256 { - t.Fatalf("expected opus normalization to 256, got %d", opusBitrate) + if opusBitrate != 320 { + t.Fatalf("expected opus normalization to 320, got %d", opusBitrate) } _, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1") @@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) { t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate) } } + +func TestParseYouTubeQualityInput_Opus320(t *testing.T) { + format, bitrate, normalized := parseYouTubeQualityInput("opus_320") + if format != "opus" { + t.Fatalf("expected opus format, got %s", format) + } + if bitrate != 320 { + t.Fatalf("expected 320 bitrate, got %d", bitrate) + } + if normalized != YouTubeQualityOpus320 { + t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized) + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ce8174e2..ae9a4cc0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3838,6 +3838,36 @@ abstract class AppLocalizations { /// **'FFmpeg metadata embed failed'** String get trackReEnrichFfmpegFailed; + /// Action/button label for queueing FLAC redownloads for local tracks + /// + /// In en, this message translates to: + /// **'Queue FLAC'** + String get queueFlacAction; + + /// Confirmation dialog body before queueing FLAC redownloads for local tracks + /// + /// In en, this message translates to: + /// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'** + String queueFlacConfirmMessage(int count); + + /// Snackbar while resolving remote matches for local FLAC redownloads + /// + /// In en, this message translates to: + /// **'Finding FLAC matches... ({current}/{total})'** + String queueFlacFindingProgress(int current, int total); + + /// Snackbar when no safe FLAC redownload matches were found + /// + /// In en, this message translates to: + /// **'No reliable online matches found for the selection'** + String get queueFlacNoReliableMatches; + + /// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped + /// + /// In en, this message translates to: + /// **'Added {addedCount} tracks to queue, skipped {skippedCount}'** + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount); + /// Snackbar when save operation fails /// /// In en, this message translates to: @@ -4602,18 +4632,6 @@ abstract class AppLocalizations { /// **'Select a built-in service to enable'** String get downloadSelectServiceToEnable; - /// Quality option label for Tidal lossy 320kbps - /// - /// In en, this message translates to: - /// **'Lossy 320kbps'** - String get downloadLossy320; - - /// Setting title to pick output format for Tidal lossy downloads - /// - /// In en, this message translates to: - /// **'Lossy Format'** - String get downloadLossyFormat; - /// Info hint when non-Tidal/Qobuz service is selected /// /// In en, this message translates to: @@ -4740,54 +4758,6 @@ abstract class AppLocalizations { /// **'Auto'** String get downloadMusixmatchAuto; - /// Title of the Tidal lossy format picker bottom sheet - /// - /// In en, this message translates to: - /// **'Lossy 320kbps Format'** - String get downloadLossy320Format; - - /// Description in the Tidal lossy format picker - /// - /// In en, this message translates to: - /// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'** - String get downloadLossy320FormatDesc; - - /// Tidal lossy format option - MP3 320kbps - /// - /// In en, this message translates to: - /// **'MP3 320kbps'** - String get downloadLossyMp3; - - /// Subtitle for MP3 320kbps option - /// - /// In en, this message translates to: - /// **'Best compatibility, ~10MB per track'** - String get downloadLossyMp3Subtitle; - - /// Tidal lossy format option - Opus 256kbps - /// - /// In en, this message translates to: - /// **'Opus 256kbps'** - String get downloadLossyOpus256; - - /// Subtitle for Opus 256kbps option - /// - /// In en, this message translates to: - /// **'Best quality Opus, ~8MB per track'** - String get downloadLossyOpus256Subtitle; - - /// Tidal lossy format option - Opus 128kbps - /// - /// In en, this message translates to: - /// **'Opus 128kbps'** - String get downloadLossyOpus128; - - /// Subtitle for Opus 128kbps option - /// - /// In en, this message translates to: - /// **'Smallest size, ~4MB per track'** - String get downloadLossyOpus128Subtitle; - /// Subtitle for 'Any' network mode option /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b913d844..ce489e3d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2172,6 +2172,28 @@ class AppLocalizationsDe extends AppLocalizations { String get trackReEnrichFfmpegFailed => 'FFmpeg Metadaten-Einbettung fehlgeschlagen'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Fehler: $error'; @@ -2666,12 +2688,6 @@ class AppLocalizationsDe extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2748,32 +2764,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 07e3ca2d..bd280e94 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2145,6 +2145,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2638,12 +2660,6 @@ class AppLocalizationsEn extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2720,32 +2736,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 8a5a974b..81ce15ff 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2145,6 +2145,28 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2638,12 +2660,6 @@ class AppLocalizationsEs extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2720,32 +2736,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 68d677e1..b35b22d0 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2147,6 +2147,28 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2640,12 +2662,6 @@ class AppLocalizationsFr extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2722,32 +2738,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 5da28983..e36316e9 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2145,6 +2145,28 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2638,12 +2660,6 @@ class AppLocalizationsHi extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2720,32 +2736,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 7fc8d1de..68bffbe5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2152,6 +2152,28 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Antrekan FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Mencari kecocokan FLAC... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2645,12 +2667,6 @@ class AppLocalizationsId extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2727,32 +2743,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 04a7a0c2..8145c794 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2132,6 +2132,28 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return '失敗: $error'; @@ -2625,12 +2647,6 @@ class AppLocalizationsJa extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2707,32 +2723,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 80e8a1df..66d21aec 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2125,6 +2125,28 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2618,12 +2640,6 @@ class AppLocalizationsKo extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2700,32 +2716,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index af603f60..e87906ac 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2145,6 +2145,28 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2638,12 +2660,6 @@ class AppLocalizationsNl extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2720,32 +2736,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index fc40c186..466572ce 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2145,6 +2145,28 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2638,12 +2660,6 @@ class AppLocalizationsPt extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2720,32 +2736,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7a542295..dd460698 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2198,6 +2198,28 @@ class AppLocalizationsRu extends AppLocalizations { String get trackReEnrichFfmpegFailed => 'Ошибка встраивания метаданных FFmpeg'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Ошибка: $error'; @@ -2697,12 +2719,6 @@ class AppLocalizationsRu extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2779,32 +2795,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 1e5727bf..6e1a37f1 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2157,6 +2157,28 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2650,12 +2672,6 @@ class AppLocalizationsTr extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2732,32 +2748,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 68381882..9248d997 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2145,6 +2145,28 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + @override + String get queueFlacAction => 'Queue FLAC'; + + @override + String queueFlacConfirmMessage(int count) { + return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected'; + } + + @override + String queueFlacFindingProgress(int current, int total) { + return 'Finding FLAC matches... ($current/$total)'; + } + + @override + String get queueFlacNoReliableMatches => + 'No reliable online matches found for the selection'; + + @override + String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) { + return 'Added $addedCount tracks to queue, skipped $skippedCount'; + } + @override String trackSaveFailed(String error) { return 'Failed: $error'; @@ -2638,12 +2660,6 @@ class AppLocalizationsZh extends AppLocalizations { String get downloadSelectServiceToEnable => 'Select a built-in service to enable'; - @override - String get downloadLossy320 => 'Lossy 320kbps'; - - @override - String get downloadLossyFormat => 'Lossy Format'; - @override String get downloadSelectTidalQobuz => 'Select Tidal or Qobuz above to configure quality'; @@ -2720,32 +2736,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadMusixmatchAuto => 'Auto'; - @override - String get downloadLossy320Format => 'Lossy 320kbps Format'; - - @override - String get downloadLossy320FormatDesc => - 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'; - - @override - String get downloadLossyMp3 => 'MP3 320kbps'; - - @override - String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track'; - - @override - String get downloadLossyOpus256 => 'Opus 256kbps'; - - @override - String get downloadLossyOpus256Subtitle => - 'Best quality Opus, ~8MB per track'; - - @override - String get downloadLossyOpus128 => 'Opus 128kbps'; - - @override - String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track'; - @override String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index bc3f2e13..3de4ec31 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2819,6 +2819,47 @@ "@trackReEnrichFfmpegFailed": { "description": "Snackbar when FFmpeg embed fails for MP3/Opus" }, + "queueFlacAction": "Queue FLAC", + "@queueFlacAction": { + "description": "Action/button label for queueing FLAC redownloads for local tracks" + }, + "queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected", + "@queueFlacConfirmMessage": { + "description": "Confirmation dialog body before queueing FLAC redownloads for local tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})", + "@queueFlacFindingProgress": { + "description": "Snackbar while resolving remote matches for local FLAC redownloads", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "queueFlacNoReliableMatches": "No reliable online matches found for the selection", + "@queueFlacNoReliableMatches": { + "description": "Snackbar when no safe FLAC redownload matches were found" + }, + "queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}", + "@queueFlacQueuedWithSkipped": { + "description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped", + "placeholders": { + "addedCount": { + "type": "int" + }, + "skippedCount": { + "type": "int" + } + } + }, "trackSaveFailed": "Failed: {error}", "@trackSaveFailed": { "description": "Snackbar when save operation fails", @@ -3513,14 +3554,7 @@ "@downloadSelectServiceToEnable": { "description": "Hint shown instead of Ask-quality subtitle when no built-in service selected" }, - "downloadLossy320": "Lossy 320kbps", - "@downloadLossy320": { - "description": "Quality option label for Tidal lossy 320kbps" - }, - "downloadLossyFormat": "Lossy Format", - "@downloadLossyFormat": { - "description": "Setting title to pick output format for Tidal lossy downloads" - }, + "downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality", "@downloadSelectTidalQobuz": { "description": "Info hint when non-Tidal/Qobuz service is selected" @@ -3606,38 +3640,7 @@ "@downloadMusixmatchAuto": { "description": "Button to reset Musixmatch language to automatic" }, - "downloadLossy320Format": "Lossy 320kbps Format", - "@downloadLossy320Format": { - "description": "Title of the Tidal lossy format picker bottom sheet" - }, - "downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.", - "@downloadLossy320FormatDesc": { - "description": "Description in the Tidal lossy format picker" - }, - "downloadLossyMp3": "MP3 320kbps", - "@downloadLossyMp3": { - "description": "Tidal lossy format option - MP3 320kbps" - }, - "downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track", - "@downloadLossyMp3Subtitle": { - "description": "Subtitle for MP3 320kbps option" - }, - "downloadLossyOpus256": "Opus 256kbps", - "@downloadLossyOpus256": { - "description": "Tidal lossy format option - Opus 256kbps" - }, - "downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track", - "@downloadLossyOpus256Subtitle": { - "description": "Subtitle for Opus 256kbps option" - }, - "downloadLossyOpus128": "Opus 128kbps", - "@downloadLossyOpus128": { - "description": "Tidal lossy format option - Opus 128kbps" - }, - "downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track", - "@downloadLossyOpus128Subtitle": { - "description": "Subtitle for Opus 128kbps option" - }, + "downloadNetworkAnySubtitle": "WiFi + Mobile Data", "@downloadNetworkAnySubtitle": { "description": "Subtitle for 'Any' network mode option" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 67e3e9ac..9bf2ee10 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -38,10 +38,8 @@ class AppSettings { final bool showExtensionStore; final String locale; final String lyricsMode; - final String - tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' final int - youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps) + youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps) final int youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps) final bool @@ -114,7 +112,6 @@ class AppSettings { this.showExtensionStore = true, this.locale = 'system', this.lyricsMode = 'embed', - this.tidalHighFormat = 'mp3_320', this.youtubeOpusBitrate = 256, this.youtubeMp3Bitrate = 320, this.useAllFilesAccess = false, @@ -178,7 +175,6 @@ class AppSettings { bool? showExtensionStore, String? locale, String? lyricsMode, - String? tidalHighFormat, int? youtubeOpusBitrate, int? youtubeMp3Bitrate, bool? useAllFilesAccess, @@ -241,7 +237,6 @@ class AppSettings { showExtensionStore: showExtensionStore ?? this.showExtensionStore, locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, - tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate, youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index c3eecb50..99a5114d 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -44,7 +44,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( showExtensionStore: json['showExtensionStore'] as bool? ?? true, locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', - tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256, youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320, useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, @@ -119,7 +118,6 @@ Map _$AppSettingsToJson( 'showExtensionStore': instance.showExtensionStore, 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, - 'tidalHighFormat': instance.tidalHighFormat, 'youtubeOpusBitrate': instance.youtubeOpusBitrate, 'youtubeMp3Bitrate': instance.youtubeMp3Bitrate, 'useAllFilesAccess': instance.useAllFilesAccess, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index df46e084..a01b8e88 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1840,7 +1840,7 @@ class DownloadQueueNotifier extends Notifier { return '.opus'; } if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { - return '.m4a'; + return '.flac'; // HIGH quality no longer available; fallback to FLAC } return '.flac'; } @@ -2383,7 +2383,8 @@ class DownloadQueueNotifier extends Notifier { backendResult['album_artist'] as String?, ); - final hasOverrides = backendTrackNum != null || + final hasOverrides = + backendTrackNum != null || backendDiscNum != null || backendYear != null || backendAlbum != null || @@ -3612,6 +3613,11 @@ class DownloadQueueNotifier extends Notifier { 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', ); } + + if (!useSaf) { + await _ensureDirExists(outputDir, label: 'Output folder'); + } + _log.d('Output dir: $outputDir'); final normalizedTrackNumber = @@ -3903,7 +3909,6 @@ class DownloadQueueNotifier extends Notifier { isContentUriPath && effectiveSafMode && actualService == 'tidal' && - quality != 'HIGH' && filePath.endsWith('.flac') && (mimeType == null || mimeType.contains('flac')); @@ -3918,73 +3923,50 @@ class DownloadQueueNotifier extends Notifier { final currentFilePath = filePath; if (isContentUriPath && effectiveSafMode) { - if (quality == 'HIGH') { - final tidalHighFormat = settings.tidalHighFormat; - _log.i( - 'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...', - ); - - final tempPath = await _copySafToTemp(currentFilePath); - if (tempPath != null) { - String? convertedPath; - try { + _log.d('M4A file detected (SAF), converting to FLAC...'); + final tempPath = await _copySafToTemp(currentFilePath); + if (tempPath != null) { + String? flacPath; + try { + final length = await File(tempPath).length(); + if (length < 1024) { + _log.w('Temp M4A is too small (<1KB), skipping conversion'); + } else { updateItemStatus( item.id, DownloadStatus.downloading, progress: 0.95, ); - - final format = tidalHighFormat.startsWith('opus') - ? 'opus' - : 'mp3'; - convertedPath = await FFmpegService.convertM4aToLossy( - tempPath, - format: format, - bitrate: tidalHighFormat, - deleteOriginal: false, - ); - - if (convertedPath != null) { - _log.i( - 'Successfully converted M4A to $format (temp): $convertedPath', - ); - _log.i('Embedding metadata to $format...'); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.99, + flacPath = await FFmpegService.convertM4aToFlac(tempPath); + if (flacPath != null) { + _log.d('Converted to FLAC (temp): $flacPath'); + _log.d('Embedding metadata and cover to converted FLAC...'); + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, ); final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; - if (format == 'mp3') { - await _embedMetadataToMp3( - convertedPath, - trackToDownload, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } else { - await _embedMetadataToOpus( - convertedPath, - trackToDownload, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } + await _embedMetadataAndCover( + flacPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + writeExternalLrc: false, + ); - final newExt = format == 'opus' ? '.opus' : '.mp3'; - final newFileName = '${safBaseName ?? 'track'}$newExt'; + final newFileName = '${safBaseName ?? 'track'}.flac'; final newUri = await _writeTempToSaf( treeUri: settings.downloadTreeUri, relativeDir: effectiveOutputDir, fileName: newFileName, - mimeType: _mimeTypeForExt(newExt), - srcPath: convertedPath, + mimeType: _mimeTypeForExt('.flac'), + srcPath: flacPath, ); if (newUri != null) { @@ -3993,58 +3975,60 @@ class DownloadQueueNotifier extends Notifier { } filePath = newUri; finalSafFileName = newFileName; - final bitrateDisplay = tidalHighFormat.contains('_') - ? '${tidalHighFormat.split('_').last}kbps' - : '320kbps'; - actualQuality = '${format.toUpperCase()} $bitrateDisplay'; } else { - _log.w( - 'Failed to write converted $format to SAF, keeping M4A', - ); - actualQuality = 'AAC 320kbps'; + _log.w('Failed to write FLAC to SAF, keeping M4A'); } } else { - _log.w( - 'M4A to $format conversion failed, keeping M4A file', - ); - actualQuality = 'AAC 320kbps'; - } - } catch (e) { - _log.w('SAF M4A conversion failed: $e'); - actualQuality = 'AAC 320kbps'; - } finally { - // Clean up temp files - try { - await File(tempPath).delete(); - } catch (_) {} - if (convertedPath != null) { - try { - await File(convertedPath).delete(); - } catch (_) {} + _log.w('FFmpeg conversion returned null, keeping M4A file'); } } - } - } else { - _log.d('M4A file detected (SAF), converting to FLAC...'); - final tempPath = await _copySafToTemp(currentFilePath); - if (tempPath != null) { - String? flacPath; + } catch (e) { + _log.w('SAF M4A->FLAC conversion failed: $e'); + } finally { + // Clean up temp files try { - final length = await File(tempPath).length(); - if (length < 1024) { - _log.w('Temp M4A is too small (<1KB), skipping conversion'); - } else { - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.95, - ); - flacPath = await FFmpegService.convertM4aToFlac(tempPath); - if (flacPath != null) { - _log.d('Converted to FLAC (temp): $flacPath'); - _log.d( - 'Embedding metadata and cover to converted FLAC...', - ); + await File(tempPath).delete(); + } catch (_) {} + if (flacPath != null) { + try { + await File(flacPath).delete(); + } catch (_) {} + } + } + } + } else { + _log.d( + 'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', + ); + + try { + final file = File(currentFilePath); + if (!await file.exists()) { + _log.e('File does not exist at path: $filePath'); + } else { + final length = await file.length(); + _log.i('File size before conversion: ${length / 1024} KB'); + + if (length < 1024) { + _log.w( + 'File is too small (<1KB), skipping conversion. Download might be corrupt.', + ); + } else { + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, + ); + final flacPath = await FFmpegService.convertM4aToFlac( + currentFilePath, + ); + + if (flacPath != null) { + filePath = flacPath; + _log.d('Converted to FLAC: $flacPath'); + + _log.d('Embedding metadata and cover to converted FLAC...'); + try { final finalTrack = _buildTrackForMetadataEmbedding( trackToDownload, result, @@ -4055,201 +4039,32 @@ class DownloadQueueNotifier extends Notifier { final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; + if (backendGenre != null || + backendLabel != null || + backendCopyright != null) { + _log.d( + 'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright', + ); + } + await _embedMetadataAndCover( flacPath, finalTrack, genre: backendGenre ?? genre, label: backendLabel ?? label, copyright: backendCopyright, - writeExternalLrc: false, - ); - - final newFileName = '${safBaseName ?? 'track'}.flac'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), - srcPath: flacPath, - ); - - if (newUri != null) { - if (newUri != currentFilePath) { - await _deleteSafFile(currentFilePath); - } - filePath = newUri; - finalSafFileName = newFileName; - } else { - _log.w('Failed to write FLAC to SAF, keeping M4A'); - } - } else { - _log.w( - 'FFmpeg conversion returned null, keeping M4A file', ); + _log.d('Metadata and cover embedded successfully'); + } catch (e) { + _log.w('Warning: Failed to embed metadata/cover: $e'); } - } - } catch (e) { - _log.w('SAF M4A->FLAC conversion failed: $e'); - } finally { - // Clean up temp files - try { - await File(tempPath).delete(); - } catch (_) {} - if (flacPath != null) { - try { - await File(flacPath).delete(); - } catch (_) {} - } - } - } - } - } else { - if (quality == 'HIGH') { - final tidalHighFormat = settings.tidalHighFormat; - _log.i( - 'Tidal HIGH quality download, converting M4A to $tidalHighFormat...', - ); - - try { - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.95, - ); - - final format = tidalHighFormat.startsWith('opus') - ? 'opus' - : 'mp3'; - final convertedPath = await FFmpegService.convertM4aToLossy( - currentFilePath, - format: format, - bitrate: tidalHighFormat, - deleteOriginal: true, - ); - - if (convertedPath != null) { - filePath = convertedPath; - final bitrateDisplay = tidalHighFormat.contains('_') - ? '${tidalHighFormat.split('_').last}kbps' - : '320kbps'; - actualQuality = '${format.toUpperCase()} $bitrateDisplay'; - _log.i( - 'Successfully converted M4A to $format: $convertedPath', - ); - - _log.i('Embedding metadata to $format...'); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.99, - ); - - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - if (format == 'mp3') { - await _embedMetadataToMp3( - convertedPath, - trackToDownload, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); } else { - await _embedMetadataToOpus( - convertedPath, - trackToDownload, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } - _log.d('Metadata embedded successfully'); - } else { - _log.w('M4A to $format conversion failed, keeping M4A file'); - actualQuality = 'AAC 320kbps'; - } - } catch (e) { - _log.w('M4A conversion process failed: $e, keeping M4A file'); - actualQuality = 'AAC 320kbps'; - } - } else { - _log.d( - 'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', - ); - - try { - final file = File(currentFilePath); - if (!await file.exists()) { - _log.e('File does not exist at path: $filePath'); - } else { - final length = await file.length(); - _log.i('File size before conversion: ${length / 1024} KB'); - - if (length < 1024) { - _log.w( - 'File is too small (<1KB), skipping conversion. Download might be corrupt.', - ); - } else { - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.95, - ); - final flacPath = await FFmpegService.convertM4aToFlac( - currentFilePath, - ); - - if (flacPath != null) { - filePath = flacPath; - _log.d('Converted to FLAC: $flacPath'); - - _log.d( - 'Embedding metadata and cover to converted FLAC...', - ); - try { - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); - - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - if (backendGenre != null || - backendLabel != null || - backendCopyright != null) { - _log.d( - 'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright', - ); - } - - await _embedMetadataAndCover( - flacPath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - _log.d('Metadata and cover embedded successfully'); - } catch (e) { - _log.w('Warning: Failed to embed metadata/cover: $e'); - } - } else { - _log.w( - 'FFmpeg conversion returned null, keeping M4A file', - ); - } + _log.w('FFmpeg conversion returned null, keeping M4A file'); } } - } catch (e) { - _log.w( - 'FFmpeg conversion process failed: $e, keeping M4A file', - ); } + } catch (e) { + _log.w('FFmpeg conversion process failed: $e, keeping M4A file'); } } } else if (metadataEmbeddingEnabled && diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index ed44fea4..e98a70e0 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,20 +1,22 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 5; +const _currentMigrationVersion = 6; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { - static const List _youtubeOpusSupportedBitrates = [128, 256]; + static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); @@ -37,6 +39,7 @@ class SettingsNotifier extends Notifier { state = AppSettings.fromJson(jsonDecode(json)); await _runMigrations(prefs); + await _normalizeIosDownloadDirectoryIfNeeded(); await _normalizeYouTubeBitratesIfNeeded(); await _normalizeSongLinkRegionIfNeeded(); } @@ -114,6 +117,10 @@ class SettingsNotifier extends Notifier { useCustomSpotifyCredentials: false, ); } + // Migration 6: Tidal HIGH quality removed — migrate to LOSSLESS + if (state.audioQuality == 'HIGH') { + state = state.copyWith(audioQuality: 'LOSSLESS'); + } state = state.copyWith(lastSeenVersion: AppInfo.version); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); @@ -189,6 +196,20 @@ class SettingsNotifier extends Notifier { await _saveSettings(); } + Future _normalizeIosDownloadDirectoryIfNeeded() async { + if (!Platform.isIOS) return; + + final currentDir = state.downloadDirectory.trim(); + if (currentDir.isEmpty) return; + + final normalizedDir = await validateOrFixIosPath(currentDir); + if (normalizedDir == currentDir) return; + + _log.i('Normalized iOS download directory: $currentDir -> $normalizedDir'); + state = state.copyWith(downloadDirectory: normalizedDir); + await _saveSettings(); + } + String _normalizeSongLinkRegion(String region) { final normalized = region.trim().toUpperCase(); if (_isoRegionPattern.hasMatch(normalized)) return normalized; @@ -430,11 +451,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setTidalHighFormat(String format) { - state = state.copyWith(tidalHighFormat: format); - _saveSettings(); - } - void setYoutubeOpusBitrate(int bitrate) { final normalized = _normalizeYouTubeOpusBitrate(bitrate); state = state.copyWith(youtubeOpusBitrate: normalized); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index c502d142..b4a6d1a7 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -300,7 +300,6 @@ class _DownloadSettingsPageState extends ConsumerState { final topPadding = normalizedHeaderTopPadding(context); final isBuiltInService = _builtInServices.contains(settings.defaultService); - final isTidalService = settings.defaultService == 'tidal'; return PopScope( canPop: true, @@ -408,35 +407,8 @@ class _DownloadSettingsPageState extends ConsumerState { onTap: () => ref .read(settingsProvider.notifier) .setAudioQuality('HI_RES_LOSSLESS'), - showDivider: isTidalService, + showDivider: false, ), - // Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus - if (isTidalService) - _QualityOption( - title: context.l10n.downloadLossy320, - subtitle: _getTidalHighFormatLabel( - settings.tidalHighFormat, - ), - isSelected: settings.audioQuality == 'HIGH', - onTap: () => ref - .read(settingsProvider.notifier) - .setAudioQuality('HIGH'), - showDivider: false, - ), - if (isTidalService && settings.audioQuality == 'HIGH') - SettingsItem( - icon: Icons.tune, - title: context.l10n.downloadLossyFormat, - subtitle: _getTidalHighFormatLabel( - settings.tidalHighFormat, - ), - onTap: () => _showTidalHighFormatPicker( - context, - ref, - settings.tidalHighFormat, - ), - showDivider: false, - ), ], if (!isBuiltInService) ...[ Padding( @@ -464,12 +436,12 @@ class _DownloadSettingsPageState extends ConsumerState { ], SettingsItem( title: context.l10n.youtubeOpusBitrateTitle, - subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)', + subtitle: '${settings.youtubeOpusBitrate}kbps (128/256/320)', onTap: () => _showYoutubeBitratePicker( context: context, title: context.l10n.youtubeOpusBitrateTitle, currentValue: settings.youtubeOpusBitrate, - options: const [128, 256], + options: const [128, 256, 320], onSave: (value) => ref .read(settingsProvider.notifier) .setYoutubeOpusBitrate(value), @@ -1691,104 +1663,6 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - String _getTidalHighFormatLabel(String format) { - switch (format) { - case 'mp3_320': - return 'MP3 320kbps'; - case 'opus_256': - return 'Opus 256kbps'; - case 'opus_128': - return 'Opus 128kbps'; - default: - return 'MP3 320kbps'; - } - } - - void _showTidalHighFormatPicker( - BuildContext context, - WidgetRef ref, - String current, - ) { - final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.downloadLossy320Format, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.downloadLossy320FormatDesc, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ListTile( - leading: const Icon(Icons.audiotrack), - title: Text(context.l10n.downloadLossyMp3), - subtitle: Text(context.l10n.downloadLossyMp3Subtitle), - trailing: current == 'mp3_320' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('mp3_320'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: Text(context.l10n.downloadLossyOpus256), - subtitle: Text(context.l10n.downloadLossyOpus256Subtitle), - trailing: current == 'opus_256' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('opus_256'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: Text(context.l10n.downloadLossyOpus128), - subtitle: Text(context.l10n.downloadLossyOpus128Subtitle), - trailing: current == 'opus_128' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('opus_128'); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], - ), - ), - ); - } - void _showNetworkModePicker( BuildContext context, WidgetRef ref, diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 41c8c330..977a31b6 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -23,7 +23,7 @@ class BuiltInService { } /// Default quality options for built-in services -/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads +/// Default quality options for each built-in service const _builtInServices = [ BuiltInService( id: 'tidal', @@ -83,9 +83,9 @@ const _builtInServices = [ label: 'YouTube', qualityOptions: [ QualityOption( - id: 'opus_256', - label: 'Opus 256kbps', - description: 'Best quality lossy (~8MB per track)', + id: 'opus_320', + label: 'Opus 320kbps', + description: 'Best quality lossy (~10MB per track)', ), QualityOption( id: 'mp3_320', @@ -146,7 +146,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { } class _DownloadServicePickerState extends ConsumerState { - static const List _youtubeOpusSupportedBitrates = [128, 256]; + static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; late String _selectedService; From 29d8a185f96adfc8b9f4ea771d687ef0613a865a Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 15 Mar 2026 20:18:29 +0700 Subject: [PATCH 11/34] fix: handle nested legacy iOS Documents path in validation Detect and recover from stale sandbox container paths embedded inside the current Documents directory. Extracts helper functions for path suffix normalization and joining to reduce duplication. --- lib/utils/file_access.dart | 50 +++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index 026fa57e..f686bc46 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -20,6 +20,22 @@ final _iosLegacyRelativeDocumentsPattern = RegExp( r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$', caseSensitive: false, ); +final _iosNestedLegacyDocumentsPattern = RegExp( + r'/Documents/Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$', + caseSensitive: false, +); + +String _normalizeRecoveredIosSuffix(String suffix) { + final trimmed = suffix.trim(); + if (trimmed.isEmpty) return ''; + return trimmed.startsWith('/') ? trimmed.substring(1) : trimmed; +} + +String _joinRecoveredIosPath(String documentsPath, String suffix) { + final normalizedSuffix = _normalizeRecoveredIosSuffix(suffix); + if (normalizedSuffix.isEmpty) return documentsPath; + return '$documentsPath/$normalizedSuffix'; +} /// Checks if a path is a valid writable directory on iOS. /// Returns false if: @@ -43,6 +59,12 @@ bool isValidIosWritablePath(String path) { return false; } + // Reject stale paths where an old sandbox container path has been embedded + // inside the current Documents directory. + if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) { + return false; + } + // Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.) // This handles cases where FilePicker returns container root final containerPattern = RegExp( @@ -70,11 +92,19 @@ Future validateOrFixIosPath( if (!Platform.isIOS) return path; final trimmed = path.trim(); + final docDir = await getApplicationDocumentsDirectory(); + + final nestedLegacyMatch = _iosNestedLegacyDocumentsPattern.firstMatch( + trimmed, + ); + if (nestedLegacyMatch != null) { + return _joinRecoveredIosPath(docDir.path, nestedLegacyMatch.group(1) ?? ''); + } + if (isValidIosWritablePath(trimmed)) { return trimmed; } - final docDir = await getApplicationDocumentsDirectory(); final candidates = []; if (trimmed.isNotEmpty) { @@ -92,14 +122,8 @@ Future validateOrFixIosPath( trimmed, ); if (legacyRelativeMatch != null) { - final suffix = (legacyRelativeMatch.group(1) ?? '').trim(); - final normalizedSuffix = suffix.startsWith('/') - ? suffix.substring(1) - : suffix; candidates.add( - normalizedSuffix.isEmpty - ? docDir.path - : '${docDir.path}/$normalizedSuffix', + _joinRecoveredIosPath(docDir.path, legacyRelativeMatch.group(1) ?? ''), ); } @@ -109,7 +133,7 @@ Future validateOrFixIosPath( final index = trimmed.indexOf(documentsMarker); if (index >= 0) { final suffix = trimmed.substring(index + documentsMarker.length).trim(); - candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix'); + candidates.add(_joinRecoveredIosPath(docDir.path, suffix)); } } @@ -181,6 +205,14 @@ IosPathValidationResult validateIosPath(String path) { ); } + if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) { + return const IosPathValidationResult( + isValid: false, + errorReason: + 'Invalid iOS app folder path. Please choose App Documents or another local folder.', + ); + } + // Check for container root without subdirectory final containerPattern = RegExp( r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+', From 967523bfc6f4ce94d600747a09eabee0eba13d9a Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 15 Mar 2026 20:18:58 +0700 Subject: [PATCH 12/34] feat: queue FLAC redownloads for local library tracks Add LocalTrackRedownloadService with confidence-scored metadata matching (ISRC, title, artist, album, duration, track/disc number, year) to find reliable online matches for locally-stored tracks. Wire up 'Queue FLAC' selection action in both local_album_screen and queue_tab (library tab). Shows progress snackbar during resolution, skips ambiguous or low-confidence matches, and reports results. Add Indonesian (id) translations for all queueFlac l10n keys. --- lib/l10n/arb/app_id.arb | 43 ++- lib/screens/local_album_screen.dart | 141 +++++++- lib/screens/queue_tab.dart | 156 +++++++- .../local_track_redownload_service.dart | 338 ++++++++++++++++++ 4 files changed, 667 insertions(+), 11 deletions(-) create mode 100644 lib/services/local_track_redownload_service.dart diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index de9d90d0..bab66bf2 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -2755,6 +2755,47 @@ "@trackReEnrichFfmpegFailed": { "description": "Snackbar when FFmpeg embed fails for MP3/Opus" }, + "queueFlacAction": "Antrekan FLAC", + "@queueFlacAction": { + "description": "Action/button label for queueing FLAC redownloads for local tracks" + }, + "queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih", + "@queueFlacConfirmMessage": { + "description": "Confirmation dialog body before queueing FLAC redownloads for local tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})", + "@queueFlacFindingProgress": { + "description": "Snackbar while resolving remote matches for local FLAC redownloads", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini", + "@queueFlacNoReliableMatches": { + "description": "Snackbar when no safe FLAC redownload matches were found" + }, + "queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}", + "@queueFlacQueuedWithSkipped": { + "description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped", + "placeholders": { + "addedCount": { + "type": "int" + }, + "skippedCount": { + "type": "int" + } + } + }, "trackSaveFailed": "Failed: {error}", "@trackSaveFailed": { "description": "Snackbar when save operation fails", @@ -3114,4 +3155,4 @@ "@downloadUseAlbumArtistForFoldersTrackSubtitle": { "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e5584b34..122a4bab 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -4,11 +4,15 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.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/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/local_track_redownload_service.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'; @@ -41,11 +45,10 @@ class _LocalAlbumScreenState extends ConsumerState { void _showCueVirtualTrackSnackBar() { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text(cueVirtualTrackRequiresSplitMessage), - ), + const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)), ); } + late List _sortedDiscNumbersCache; late bool _hasMultipleDiscsCache; String? _commonQualityCache; @@ -897,6 +900,127 @@ class _LocalAlbumScreenState extends ConsumerState { return false; } + Future _queueSelectedAsFlac(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; + } + + 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, + ); + final targetQuality = + LocalTrackRedownloadService.preferredFlacQualityForService( + targetService, + ); + + final matchedTracks = []; + var skippedCount = 0; + final total = selected.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.queueFlacFindingProgress(i + 1, total), + ), + duration: const Duration(seconds: 30), + ), + ); + + 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; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + + 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; + }); + } + Future _reEnrichSelected(List allTracks) async { final tracksById = {for (final t in allTracks) t.id: t}; final selected = []; @@ -1525,6 +1649,17 @@ class _LocalAlbumScreenState extends ConsumerState { Row( children: [ + Expanded( + child: _LocalAlbumSelectionActionButton( + icon: Icons.download_for_offline_outlined, + label: '${context.l10n.queueFlacAction} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _queueSelectedAsFlac(tracks) + : null, + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), Expanded( child: _LocalAlbumSelectionActionButton( icon: Icons.auto_fix_high_outlined, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 5b14ff21..52ca3309 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -17,11 +17,13 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.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/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -1321,8 +1323,10 @@ class _QueueTabState extends ConsumerState { .where((p) => _selectedPlaylistIds.contains(p.id)) .toList(); - final totalTracks = - selectedPlaylists.fold(0, (sum, p) => sum + p.tracks.length); + final totalTracks = selectedPlaylists.fold( + 0, + (sum, p) => sum + p.tracks.length, + ); if (totalTracks == 0) { ScaffoldMessenger.of(context).showSnackBar( @@ -1336,7 +1340,10 @@ class _QueueTabState extends ConsumerState { builder: (ctx) => AlertDialog( title: Text(ctx.l10n.dialogDownloadAllTitle), content: Text( - ctx.l10n.dialogDownloadPlaylistsMessage(totalTracks, selectedPlaylists.length), + ctx.l10n.dialogDownloadPlaylistsMessage( + totalTracks, + selectedPlaylists.length, + ), ), actions: [ TextButton( @@ -1392,9 +1399,7 @@ class _QueueTabState extends ConsumerState { _exitPlaylistSelectionMode(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToQueue(totalTracks), - ), + content: Text(context.l10n.snackbarAddedTracksToQueue(totalTracks)), ), ); } @@ -1547,7 +1552,9 @@ class _QueueTabState extends ConsumerState { icon: const Icon(Icons.download_rounded), label: Text( selectedCount > 0 - ? context.l10n.bulkDownloadPlaylistsButton(selectedCount) + ? context.l10n.bulkDownloadPlaylistsButton( + selectedCount, + ) : context.l10n.bulkDownloadSelectPlaylists, ), style: FilledButton.styleFrom( @@ -4477,6 +4484,127 @@ class _QueueTabState extends ConsumerState { return false; } + Future _queueSelectedLocalAsFlac( + List allItems, + ) async { + final selectedItems = _selectedItemsFromAll(allItems); + final selectedLocalItems = selectedItems + .map((item) => item.localItem) + .whereType() + .toList(growable: false); + + 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, + ); + final targetQuality = + LocalTrackRedownloadService.preferredFlacQualityForService( + targetService, + ); + + final matchedTracks = []; + var skippedCount = 0; + final total = selectedLocalItems.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.queueFlacFindingProgress(i + 1, total), + ), + duration: const Duration(seconds: 30), + ), + ); + + 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; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + + 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; + }); + } + Future _reEnrichSelectedLocalFromQueue( List allItems, ) async { @@ -5244,6 +5372,20 @@ class _QueueTabState extends ConsumerState { // Action buttons row: Share/Re-enrich, Convert, Delete Row( children: [ + if (localOnlySelection) ...[ + Expanded( + child: _SelectionActionButton( + icon: Icons.download_for_offline_outlined, + label: + '${context.l10n.queueFlacAction} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _queueSelectedLocalAsFlac(unifiedItems) + : null, + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), + ], Expanded( child: _SelectionActionButton( icon: localOnlySelection diff --git a/lib/services/local_track_redownload_service.dart b/lib/services/local_track_redownload_service.dart new file mode 100644 index 00000000..4b8500c7 --- /dev/null +++ b/lib/services/local_track_redownload_service.dart @@ -0,0 +1,338 @@ +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +class LocalTrackRedownloadResolution { + final LocalLibraryItem localItem; + final Track? match; + final int score; + final String reason; + + const LocalTrackRedownloadResolution({ + required this.localItem, + required this.match, + required this.score, + required this.reason, + }); + + bool get canQueue => match != null; +} + +class LocalTrackRedownloadService { + static const int _minimumConfidenceScore = 85; + static const int _ambiguousScoreGap = 8; + + static Future resolveBestMatch( + LocalLibraryItem item, { + required bool includeExtensions, + }) async { + final query = _buildSearchQuery(item); + final rawResults = await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 10, + includeExtensions: includeExtensions, + ); + + if (rawResults.isEmpty) { + return LocalTrackRedownloadResolution( + localItem: item, + match: null, + score: 0, + reason: 'No candidates found', + ); + } + + final scored = + rawResults + .map( + (raw) => ( + track: _parseSearchTrack(raw), + score: _scoreMatch(item, raw), + ), + ) + .where((entry) => entry.track.name.trim().isNotEmpty) + .toList(growable: false) + ..sort((a, b) => b.score.compareTo(a.score)); + + if (scored.isEmpty) { + return LocalTrackRedownloadResolution( + localItem: item, + match: null, + score: 0, + reason: 'No usable candidates found', + ); + } + + final best = scored.first; + final runnerUp = scored.length > 1 ? scored[1] : null; + final exactIsrc = + _normalizedIsrc(item.isrc) != null && + _normalizedIsrc(item.isrc) == _normalizedIsrc(best.track.isrc); + final isAmbiguous = + !exactIsrc && + runnerUp != null && + best.score < (_minimumConfidenceScore + 10) && + (best.score - runnerUp.score) <= _ambiguousScoreGap; + + if (!exactIsrc && (best.score < _minimumConfidenceScore || isAmbiguous)) { + return LocalTrackRedownloadResolution( + localItem: item, + match: null, + score: best.score, + reason: isAmbiguous ? 'Ambiguous match' : 'Low-confidence match', + ); + } + + return LocalTrackRedownloadResolution( + localItem: item, + match: best.track, + score: best.score, + reason: exactIsrc ? 'Exact ISRC match' : 'High-confidence metadata match', + ); + } + + static String preferredFlacService(AppSettings settings) { + switch (settings.defaultService.toLowerCase()) { + case 'tidal': + case 'qobuz': + case 'deezer': + return settings.defaultService.toLowerCase(); + default: + return 'tidal'; + } + } + + static String preferredFlacQualityForService(String service) { + return service.toLowerCase() == 'deezer' ? 'FLAC' : 'LOSSLESS'; + } + + static String _buildSearchQuery(LocalLibraryItem item) { + final artist = _primaryArtist(item.artistName); + final album = item.albumName.trim(); + if (album.isNotEmpty && album.toLowerCase() != 'unknown album') { + return '${item.trackName} $artist $album'.trim(); + } + return '${item.trackName} $artist'.trim(); + } + + static Track _parseSearchTrack(Map data) { + final durationMs = _extractDurationMs(data); + final itemType = data['item_type']?.toString(); + + return Track( + id: (data['spotify_id'] ?? data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? data['album'] ?? '').toString(), + albumArtist: data['album_artist']?.toString(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), + coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date']?.toString(), + totalTracks: data['total_tracks'] as int?, + source: data['source']?.toString() ?? data['provider_id']?.toString(), + albumType: data['album_type']?.toString(), + itemType: itemType, + ); + } + + static int _extractDurationMs(Map data) { + final durationMsRaw = data['duration_ms']; + if (durationMsRaw is num && durationMsRaw > 0) { + return durationMsRaw.toInt(); + } + if (durationMsRaw is String) { + final parsed = num.tryParse(durationMsRaw.trim()); + if (parsed != null && parsed > 0) { + return parsed.toInt(); + } + } + + final durationSecRaw = data['duration']; + if (durationSecRaw is num && durationSecRaw > 0) { + return (durationSecRaw * 1000).toInt(); + } + if (durationSecRaw is String) { + final parsed = num.tryParse(durationSecRaw.trim()); + if (parsed != null && parsed > 0) { + return (parsed * 1000).toInt(); + } + } + + return 0; + } + + static int _scoreMatch(LocalLibraryItem item, Map raw) { + final track = _parseSearchTrack(raw); + var score = 0; + + final localIsrc = _normalizedIsrc(item.isrc); + final candidateIsrc = _normalizedIsrc(track.isrc); + if (localIsrc != null && candidateIsrc != null) { + score += localIsrc == candidateIsrc ? 140 : -120; + } + + final localTitle = _normalizedTitle(item.trackName); + final candidateTitle = _normalizedTitle(track.name); + if (localTitle == candidateTitle) { + score += 45; + } else if (_tokenOverlap(localTitle, candidateTitle) >= 0.75) { + score += 24; + } else { + score -= 25; + } + + final localArtist = _normalizedArtistGroup(item.artistName); + final candidateArtist = _normalizedArtistGroup(track.artistName); + final artistOverlap = _tokenOverlap(localArtist, candidateArtist); + if (localArtist == candidateArtist) { + score += 30; + } else if (artistOverlap >= 0.6) { + score += 16; + } else { + score -= 20; + } + + final localAlbum = _normalizedText(item.albumName); + final candidateAlbum = _normalizedText(track.albumName); + if (localAlbum.isNotEmpty && candidateAlbum.isNotEmpty) { + if (localAlbum == candidateAlbum) { + score += 12; + } else if (_tokenOverlap(localAlbum, candidateAlbum) >= 0.7) { + score += 6; + } + } + + final localDuration = item.duration ?? 0; + final candidateDuration = track.duration; + if (localDuration > 0 && candidateDuration > 0) { + final diff = (localDuration - candidateDuration).abs(); + if (diff <= 2) { + score += 20; + } else if (diff <= 5) { + score += 12; + } else if (diff <= 10) { + score += 5; + } else if (diff > 20) { + score -= 30; + } + } + + if (item.trackNumber != null && + track.trackNumber != null && + item.trackNumber == track.trackNumber) { + score += 6; + } + if (item.discNumber != null && + track.discNumber != null && + item.discNumber == track.discNumber) { + score += 4; + } + + final localYear = _extractYear(item.releaseDate); + final candidateYear = _extractYear(track.releaseDate); + if (localYear != null && + candidateYear != null && + localYear == candidateYear) { + score += 4; + } + + score += _versionPenalty(item.trackName, track.name); + return score; + } + + static String? _normalizedIsrc(String? value) { + final normalized = value?.trim().toUpperCase(); + if (normalized == null || normalized.isEmpty) { + return null; + } + return normalized; + } + + static String _normalizedTitle(String value) { + final cleaned = _normalizedText(value) + .replaceAll(RegExp(r'\b(feat|ft|featuring)\b.*$'), ' ') + .replaceAll(RegExp(r'\b(remaster(?:ed)?|deluxe|bonus)\b'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return cleaned; + } + + static String _normalizedArtistGroup(String value) { + return _normalizedText( + value + .replaceAll(RegExp(r'\b(feat|ft|featuring|with|x)\b'), ',') + .replaceAll('&', ','), + ); + } + + static String _primaryArtist(String value) { + final parts = _normalizedArtistGroup( + value, + ).split(',').map((part) => part.trim()).where((part) => part.isNotEmpty); + return parts.isEmpty ? value.trim() : parts.first; + } + + static String _normalizedText(String value) { + return value + .toLowerCase() + .replaceAll(RegExp(r'[\(\)\[\]\{\}]'), ' ') + .replaceAll(RegExp(r'[^a-z0-9, ]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } + + static double _tokenOverlap(String left, String right) { + final leftTokens = left + .split(RegExp(r'[\s,]+')) + .where((token) => token.isNotEmpty) + .toSet(); + final rightTokens = right + .split(RegExp(r'[\s,]+')) + .where((token) => token.isNotEmpty) + .toSet(); + if (leftTokens.isEmpty || rightTokens.isEmpty) { + return 0; + } + final intersection = leftTokens.intersection(rightTokens).length; + final denominator = leftTokens.length > rightTokens.length + ? leftTokens.length + : rightTokens.length; + return intersection / denominator; + } + + static int _versionPenalty(String localTitle, String candidateTitle) { + const riskyMarkers = [ + 'live', + 'karaoke', + 'instrumental', + 'acoustic', + 'radio edit', + 'sped up', + 'slowed', + ]; + final local = _normalizedText(localTitle); + final candidate = _normalizedText(candidateTitle); + var penalty = 0; + for (final marker in riskyMarkers) { + final localHas = local.contains(marker); + final candidateHas = candidate.contains(marker); + if (!localHas && candidateHas) { + penalty -= 18; + } + } + return penalty; + } + + static int? _extractYear(String? date) { + if (date == null || date.length < 4) { + return null; + } + return int.tryParse(date.substring(0, 4)); + } +} From b7f34ec47ceb8f31cc386b8c3ef888c126d6e203 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 15 Mar 2026 20:35:42 +0700 Subject: [PATCH 13/34] feat: selective auto-fill from online in Edit Metadata sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'Auto-fill from online' expandable section to the metadata editor that lets users choose exactly which fields to populate from online metadata search. Users can select individual fields via filter chips, use 'All' or 'Empty only' quick-select buttons, then tap 'Fetch & Fill' to search metadata providers and fill only the selected controllers. The search uses existing searchTracksWithMetadataProviders API with ISRC-preferring best-match selection. Extended metadata (genre, label, copyright) is fetched via Deezer extended metadata API when available. Cover art is downloaded from the match's cover_url. All results are previewed in the editor before saving — nothing is written to the file until the user taps Save. Add 21 new l10n keys (editMetadata* namespace) for all UI strings. --- lib/l10n/app_localizations.dart | 126 +++++++ lib/l10n/app_localizations_de.dart | 74 ++++ lib/l10n/app_localizations_en.dart | 74 ++++ lib/l10n/app_localizations_es.dart | 74 ++++ lib/l10n/app_localizations_fr.dart | 74 ++++ lib/l10n/app_localizations_hi.dart | 74 ++++ lib/l10n/app_localizations_id.dart | 74 ++++ lib/l10n/app_localizations_ja.dart | 74 ++++ lib/l10n/app_localizations_ko.dart | 74 ++++ lib/l10n/app_localizations_nl.dart | 74 ++++ lib/l10n/app_localizations_pt.dart | 74 ++++ lib/l10n/app_localizations_ru.dart | 74 ++++ lib/l10n/app_localizations_tr.dart | 74 ++++ lib/l10n/app_localizations_zh.dart | 74 ++++ lib/l10n/arb/app_en.arb | 89 +++++ lib/screens/track_metadata_screen.dart | 479 +++++++++++++++++++++++++ 16 files changed, 1656 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ae9a4cc0..01af801f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4817,6 +4817,132 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{count, plural, =1{1 playlist} other{{count} playlists}}'** String playlistsCount(int count); + + /// Section title for selective online metadata auto-fill in the edit metadata sheet + /// + /// In en, this message translates to: + /// **'Auto-fill from online'** + String get editMetadataAutoFill; + + /// Description for the auto-fill section + /// + /// In en, this message translates to: + /// **'Select fields to fill automatically from online metadata'** + String get editMetadataAutoFillDesc; + + /// Button label to fetch online metadata and fill selected fields + /// + /// In en, this message translates to: + /// **'Fetch & Fill'** + String get editMetadataAutoFillFetch; + + /// Snackbar shown while searching for online metadata + /// + /// In en, this message translates to: + /// **'Searching online...'** + String get editMetadataAutoFillSearching; + + /// Snackbar when online metadata search returns no results + /// + /// In en, this message translates to: + /// **'No matching metadata found online'** + String get editMetadataAutoFillNoResults; + + /// Snackbar confirming how many fields were auto-filled + /// + /// In en, this message translates to: + /// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'** + String editMetadataAutoFillDone(int count); + + /// Snackbar when user taps Fetch without selecting any fields + /// + /// In en, this message translates to: + /// **'Select at least one field to auto-fill'** + String get editMetadataAutoFillNoneSelected; + + /// Chip label for title field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Title'** + String get editMetadataFieldTitle; + + /// Chip label for artist field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Artist'** + String get editMetadataFieldArtist; + + /// Chip label for album field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Album'** + String get editMetadataFieldAlbum; + + /// Chip label for album artist field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Album Artist'** + String get editMetadataFieldAlbumArtist; + + /// Chip label for date field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Date'** + String get editMetadataFieldDate; + + /// Chip label for track number field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Track #'** + String get editMetadataFieldTrackNum; + + /// Chip label for disc number field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Disc #'** + String get editMetadataFieldDiscNum; + + /// Chip label for genre field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Genre'** + String get editMetadataFieldGenre; + + /// Chip label for ISRC field in auto-fill selector + /// + /// In en, this message translates to: + /// **'ISRC'** + String get editMetadataFieldIsrc; + + /// Chip label for label field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Label'** + String get editMetadataFieldLabel; + + /// Chip label for copyright field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Copyright'** + String get editMetadataFieldCopyright; + + /// Chip label for cover art field in auto-fill selector + /// + /// In en, this message translates to: + /// **'Cover Art'** + String get editMetadataFieldCover; + + /// Button to select all fields for auto-fill + /// + /// In en, this message translates to: + /// **'All'** + String get editMetadataSelectAll; + + /// Button to select only fields that are currently empty + /// + /// In en, this message translates to: + /// **'Empty only'** + String get editMetadataSelectEmpty; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ce489e3d..c3ee5083 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2826,4 +2826,78 @@ class AppLocalizationsDe extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bd280e94..180810f3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2798,4 +2798,78 @@ class AppLocalizationsEn extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 81ce15ff..cea3915b 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2798,6 +2798,80 @@ class AppLocalizationsEs extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index b35b22d0..8270c7c2 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2800,4 +2800,78 @@ class AppLocalizationsFr extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index e36316e9..1767c438 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2798,4 +2798,78 @@ class AppLocalizationsHi extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 68bffbe5..67a9c2fc 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2805,4 +2805,78 @@ class AppLocalizationsId extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 8145c794..e406ba35 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2785,4 +2785,78 @@ class AppLocalizationsJa extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 66d21aec..b8085a70 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2778,4 +2778,78 @@ class AppLocalizationsKo extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index e87906ac..c54e044a 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2798,4 +2798,78 @@ class AppLocalizationsNl extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 466572ce..ac0f577d 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2798,6 +2798,80 @@ class AppLocalizationsPt extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index dd460698..76814d38 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2857,4 +2857,78 @@ class AppLocalizationsRu extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 6e1a37f1..f24d9f88 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2810,4 +2810,78 @@ class AppLocalizationsTr extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9248d997..d9440cff 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2798,6 +2798,80 @@ class AppLocalizationsZh extends AppLocalizations { ); return '$_temp0'; } + + @override + String get editMetadataAutoFill => 'Auto-fill from online'; + + @override + String get editMetadataAutoFillDesc => + 'Select fields to fill automatically from online metadata'; + + @override + String get editMetadataAutoFillFetch => 'Fetch & Fill'; + + @override + String get editMetadataAutoFillSearching => 'Searching online...'; + + @override + String get editMetadataAutoFillNoResults => + 'No matching metadata found online'; + + @override + String editMetadataAutoFillDone(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from online metadata'; + } + + @override + String get editMetadataAutoFillNoneSelected => + 'Select at least one field to auto-fill'; + + @override + String get editMetadataFieldTitle => 'Title'; + + @override + String get editMetadataFieldArtist => 'Artist'; + + @override + String get editMetadataFieldAlbum => 'Album'; + + @override + String get editMetadataFieldAlbumArtist => 'Album Artist'; + + @override + String get editMetadataFieldDate => 'Date'; + + @override + String get editMetadataFieldTrackNum => 'Track #'; + + @override + String get editMetadataFieldDiscNum => 'Disc #'; + + @override + String get editMetadataFieldGenre => 'Genre'; + + @override + String get editMetadataFieldIsrc => 'ISRC'; + + @override + String get editMetadataFieldLabel => 'Label'; + + @override + String get editMetadataFieldCopyright => 'Copyright'; + + @override + String get editMetadataFieldCover => 'Cover Art'; + + @override + String get editMetadataSelectAll => 'All'; + + @override + String get editMetadataSelectEmpty => 'Empty only'; } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 3de4ec31..6da406d0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3706,5 +3706,94 @@ "type": "int" } } + }, + "editMetadataAutoFill": "Auto-fill from online", + "@editMetadataAutoFill": { + "description": "Section title for selective online metadata auto-fill in the edit metadata sheet" + }, + "editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata", + "@editMetadataAutoFillDesc": { + "description": "Description for the auto-fill section" + }, + "editMetadataAutoFillFetch": "Fetch & Fill", + "@editMetadataAutoFillFetch": { + "description": "Button label to fetch online metadata and fill selected fields" + }, + "editMetadataAutoFillSearching": "Searching online...", + "@editMetadataAutoFillSearching": { + "description": "Snackbar shown while searching for online metadata" + }, + "editMetadataAutoFillNoResults": "No matching metadata found online", + "@editMetadataAutoFillNoResults": { + "description": "Snackbar when online metadata search returns no results" + }, + "editMetadataAutoFillDone": "Filled {count} {count, plural, =1{field} other{fields}} from online metadata", + "@editMetadataAutoFillDone": { + "description": "Snackbar confirming how many fields were auto-filled", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editMetadataAutoFillNoneSelected": "Select at least one field to auto-fill", + "@editMetadataAutoFillNoneSelected": { + "description": "Snackbar when user taps Fetch without selecting any fields" + }, + "editMetadataFieldTitle": "Title", + "@editMetadataFieldTitle": { + "description": "Chip label for title field in auto-fill selector" + }, + "editMetadataFieldArtist": "Artist", + "@editMetadataFieldArtist": { + "description": "Chip label for artist field in auto-fill selector" + }, + "editMetadataFieldAlbum": "Album", + "@editMetadataFieldAlbum": { + "description": "Chip label for album field in auto-fill selector" + }, + "editMetadataFieldAlbumArtist": "Album Artist", + "@editMetadataFieldAlbumArtist": { + "description": "Chip label for album artist field in auto-fill selector" + }, + "editMetadataFieldDate": "Date", + "@editMetadataFieldDate": { + "description": "Chip label for date field in auto-fill selector" + }, + "editMetadataFieldTrackNum": "Track #", + "@editMetadataFieldTrackNum": { + "description": "Chip label for track number field in auto-fill selector" + }, + "editMetadataFieldDiscNum": "Disc #", + "@editMetadataFieldDiscNum": { + "description": "Chip label for disc number field in auto-fill selector" + }, + "editMetadataFieldGenre": "Genre", + "@editMetadataFieldGenre": { + "description": "Chip label for genre field in auto-fill selector" + }, + "editMetadataFieldIsrc": "ISRC", + "@editMetadataFieldIsrc": { + "description": "Chip label for ISRC field in auto-fill selector" + }, + "editMetadataFieldLabel": "Label", + "@editMetadataFieldLabel": { + "description": "Chip label for label field in auto-fill selector" + }, + "editMetadataFieldCopyright": "Copyright", + "@editMetadataFieldCopyright": { + "description": "Chip label for copyright field in auto-fill selector" + }, + "editMetadataFieldCover": "Cover Art", + "@editMetadataFieldCover": { + "description": "Chip label for cover art field in auto-fill selector" + }, + "editMetadataSelectAll": "All", + "@editMetadataSelectAll": { + "description": "Button to select all fields for auto-fill" + }, + "editMetadataSelectEmpty": "Empty only", + "@editMetadataSelectEmpty": { + "description": "Button to select only fields that are currently empty" } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index cd37ee6a..989b6a47 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3932,6 +3932,8 @@ class _EditMetadataSheet extends StatefulWidget { class _EditMetadataSheetState extends State<_EditMetadataSheet> { bool _saving = false; bool _showAdvanced = false; + bool _showAutoFill = false; + bool _fetching = false; String? _selectedCoverPath; String? _selectedCoverTempDir; String? _selectedCoverName; @@ -3939,6 +3941,25 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String? _currentCoverTempDir; bool _loadingCurrentCover = false; + // Auto-fill field selection — which fields the user wants to fetch + final Set _autoFillFields = {}; + + // All auto-fillable fields and their mapping + static const _fieldDefs = { + 'title': 'title', + 'artist': 'artist', + 'album': 'album', + 'album_artist': 'album_artist', + 'date': 'date', + 'track_number': 'track_number', + 'disc_number': 'disc_number', + 'genre': 'genre', + 'isrc': 'isrc', + 'label': 'label', + 'copyright': 'copyright', + 'cover': 'cover', + }; + late final TextEditingController _titleCtrl; late final TextEditingController _artistCtrl; late final TextEditingController _albumCtrl; @@ -4132,6 +4153,286 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } + String _fieldLabel(String key) { + final l10n = context.l10n; + switch (key) { + case 'title': + return l10n.editMetadataFieldTitle; + case 'artist': + return l10n.editMetadataFieldArtist; + case 'album': + return l10n.editMetadataFieldAlbum; + case 'album_artist': + return l10n.editMetadataFieldAlbumArtist; + case 'date': + return l10n.editMetadataFieldDate; + case 'track_number': + return l10n.editMetadataFieldTrackNum; + case 'disc_number': + return l10n.editMetadataFieldDiscNum; + case 'genre': + return l10n.editMetadataFieldGenre; + case 'isrc': + return l10n.editMetadataFieldIsrc; + case 'label': + return l10n.editMetadataFieldLabel; + case 'copyright': + return l10n.editMetadataFieldCopyright; + case 'cover': + return l10n.editMetadataFieldCover; + default: + return key; + } + } + + TextEditingController? _controllerForKey(String key) { + switch (key) { + case 'title': + return _titleCtrl; + case 'artist': + return _artistCtrl; + case 'album': + return _albumCtrl; + case 'album_artist': + return _albumArtistCtrl; + case 'date': + return _dateCtrl; + case 'track_number': + return _trackNumCtrl; + case 'disc_number': + return _discNumCtrl; + case 'genre': + return _genreCtrl; + case 'isrc': + return _isrcCtrl; + case 'label': + return _labelCtrl; + case 'copyright': + return _copyrightCtrl; + default: + return null; + } + } + + void _selectAllFields() { + setState(() { + _autoFillFields.addAll(_fieldDefs.keys); + }); + } + + void _selectEmptyFields() { + setState(() { + _autoFillFields.clear(); + for (final key in _fieldDefs.keys) { + if (key == 'cover') { + if (!_hasValue(_currentCoverPath) && !_hasValue(_selectedCoverPath)) { + _autoFillFields.add(key); + } + continue; + } + final ctrl = _controllerForKey(key); + if (ctrl != null && ctrl.text.trim().isEmpty) { + _autoFillFields.add(key); + } + } + }); + } + + Future _fetchAndFill() async { + if (_autoFillFields.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoneSelected)), + ); + return; + } + + setState(() => _fetching = true); + + try { + // Build search query from current field values + final title = _titleCtrl.text.trim(); + final artist = _artistCtrl.text.trim(); + final album = _albumCtrl.text.trim(); + final queryParts = []; + if (title.isNotEmpty) queryParts.add(title); + if (artist.isNotEmpty) queryParts.add(artist); + if (album.isNotEmpty) queryParts.add(album); + + if (queryParts.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.editMetadataAutoFillNoResults), + ), + ); + } + return; + } + + final query = queryParts.join(' '); + final results = await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 5, + ); + + if (!mounted) return; + + if (results.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + ); + return; + } + + // Pick best match: prefer ISRC match, then first result + final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); + Map? best; + if (currentIsrc.isNotEmpty) { + for (final r in results) { + final candidateIsrc = + (r['isrc']?.toString() ?? '').trim().toUpperCase(); + if (candidateIsrc == currentIsrc) { + best = r; + break; + } + } + } + best ??= results.first; + + // Extract metadata from best match + final enriched = { + 'title': (best['name'] ?? '').toString(), + 'artist': (best['artists'] ?? best['artist'] ?? '').toString(), + 'album': (best['album_name'] ?? best['album'] ?? '').toString(), + 'album_artist': (best['album_artist'] ?? '').toString(), + 'date': (best['release_date'] ?? '').toString(), + 'track_number': (best['track_number'] ?? '').toString(), + 'disc_number': (best['disc_number'] ?? '').toString(), + 'isrc': (best['isrc'] ?? '').toString(), + }; + + // Try to get extended metadata (genre, label, copyright) from Deezer + final trackId = + (best['spotify_id'] ?? best['id'] ?? '').toString(); + final source = (best['source'] ?? best['provider_id'] ?? '').toString(); + + if ((_autoFillFields.contains('genre') || + _autoFillFields.contains('label') || + _autoFillFields.contains('copyright')) && + trackId.isNotEmpty) { + try { + // If source is Deezer, fetch extended metadata directly + Map? extended; + if (source.toLowerCase().contains('deezer')) { + extended = await PlatformBridge.getDeezerExtendedMetadata(trackId); + } else { + // Try ISRC lookup via Deezer for genre/label/copyright + final isrcForLookup = enriched['isrc'] ?? ''; + if (isrcForLookup.isNotEmpty) { + try { + final deezerResult = await PlatformBridge.searchDeezerByISRC( + isrcForLookup, + ); + final deezerTrackId = + (deezerResult['id'] ?? deezerResult['track_id'] ?? '') + .toString(); + if (deezerTrackId.isNotEmpty) { + extended = await PlatformBridge.getDeezerExtendedMetadata( + deezerTrackId, + ); + } + } catch (_) {} + } + } + if (extended != null) { + enriched['genre'] = extended['genre'] ?? ''; + enriched['label'] = extended['label'] ?? ''; + enriched['copyright'] = extended['copyright'] ?? ''; + } + } catch (_) { + // Extended metadata is best-effort + } + } + + if (!mounted) return; + + // Apply selected fields to controllers + var filledCount = 0; + for (final key in _autoFillFields) { + if (key == 'cover') continue; // Handle cover separately below + final value = enriched[key]; + if (value != null && value.isNotEmpty && value != '0' && value != 'null') { + final ctrl = _controllerForKey(key); + if (ctrl != null) { + ctrl.text = value; + filledCount++; + } + } + } + + // Handle cover art download + if (_autoFillFields.contains('cover')) { + final coverUrl = + (best['cover_url'] ?? best['images'] ?? '').toString(); + if (coverUrl.isNotEmpty) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'autofill_cover_', + ); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final response = await HttpClient() + .getUrl(Uri.parse(coverUrl)) + .then((req) => req.close()); + final file = File(coverOutput); + final sink = file.openWrite(); + await response.pipe(sink); + if (await file.exists() && await file.length() > 0) { + await _cleanupSelectedCoverTemp(); + if (mounted) { + setState(() { + _selectedCoverPath = coverOutput; + _selectedCoverTempDir = tempDir.path; + _selectedCoverName = 'Online cover'; + }); + filledCount++; + } + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) { + // Cover download is best-effort + } + } + } + + if (mounted) { + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + filledCount > 0 + ? context.l10n.editMetadataAutoFillDone(filledCount) + : context.l10n.editMetadataAutoFillNoResults, + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarError(e.toString())), + ), + ); + } + } finally { + if (mounted) setState(() => _fetching = false); + } + } + @override void initState() { super.initState(); @@ -4416,6 +4717,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { children: [ const SizedBox(height: 6), _buildCoverEditor(cs), + _buildAutoFillSection(cs), _field('Title', _titleCtrl), _field('Artist', _artistCtrl), _field('Album', _albumCtrl), @@ -4487,6 +4789,183 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } + Widget _buildAutoFillSection(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => setState(() => _showAutoFill = !_showAutoFill), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + Icon( + Icons.travel_explore, + size: 20, + color: cs.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.editMetadataAutoFill, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: cs.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + Icon( + _showAutoFill ? Icons.expand_less : Icons.expand_more, + size: 20, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + if (_showAutoFill) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + context.l10n.editMetadataAutoFillDesc, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 8), + // Quick select buttons + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + _quickSelectButton( + label: context.l10n.editMetadataSelectAll, + onTap: _selectAllFields, + cs: cs, + ), + const SizedBox(width: 8), + _quickSelectButton( + label: context.l10n.editMetadataSelectEmpty, + onTap: _selectEmptyFields, + cs: cs, + ), + ], + ), + ), + const SizedBox(height: 8), + // Field chips + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Wrap( + spacing: 6, + runSpacing: 4, + children: _fieldDefs.keys.map((key) { + final selected = _autoFillFields.contains(key); + return FilterChip( + label: Text(_fieldLabel(key)), + selected: selected, + onSelected: _fetching + ? null + : (val) { + setState(() { + if (val) { + _autoFillFields.add(key); + } else { + _autoFillFields.remove(key); + } + }); + }, + selectedColor: cs.primaryContainer, + checkmarkColor: cs.onPrimaryContainer, + labelStyle: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: selected + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }).toList(), + ), + ), + const SizedBox(height: 10), + // Fetch button + Padding( + padding: const EdgeInsets.only( + left: 12, + right: 12, + bottom: 12, + ), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: + (_fetching || _saving || _autoFillFields.isEmpty) + ? null + : _fetchAndFill, + icon: _fetching + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.auto_fix_high), + label: Text( + _fetching + ? context.l10n.editMetadataAutoFillSearching + : context.l10n.editMetadataAutoFillFetch, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _quickSelectButton({ + required String label, + required VoidCallback onTap, + required ColorScheme cs, + }) { + return InkWell( + onTap: _fetching ? null : onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.5)), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: cs.primary, + ), + ), + ), + ); + } + Widget _buildCoverEditor(ColorScheme cs) { final hasSelectedCover = _hasValue(_selectedCoverPath); final hasCurrentCover = _hasValue(_currentCoverPath); From 89a38af538a0499b6e1adda9f0eceaf1087d3abe Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 15 Mar 2026 20:42:22 +0700 Subject: [PATCH 14/34] fix: resolve all flutter analyze warnings and improve auto-fill enrichment chain - Fix use_build_context_synchronously in _embedLyrics by capturing l10n strings before async gaps (snackbarFailedToWriteStorage, snackbarFailedToEmbedLyrics, snackbarUnsupportedAudioFormat) - Improve auto-fill metadata enrichment to use proper API chain: search providers -> convertSpotifyToDeezer (SongLink) for Deezer ID -> getDeezerMetadata for ISRC -> getDeezerExtendedMetadata for genre/label/copyright. Falls back to ISRC-based Deezer lookup when SongLink conversion unavailable. - flutter analyze now reports 0 issues --- lib/screens/track_metadata_screen.dart | 135 +++++++++++++++++++------ 1 file changed, 103 insertions(+), 32 deletions(-) diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 989b6a47..5dde4282 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -1757,6 +1757,11 @@ class _TrackMetadataScreenState extends ConsumerState { setState(() => _isEmbedding = true); + // Capture l10n strings before async gaps to avoid use_build_context_synchronously + final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage; + final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics; + final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat; + String? safTempPath; String? coverPath; @@ -1793,13 +1798,13 @@ class _TrackMetadataScreenState extends ConsumerState { ); success = ok; if (!ok) { - error = context.l10n.snackbarFailedToWriteStorage; + error = l10nFailedToWriteStorage; } } else { success = true; } } else { - error = result['error']?.toString() ?? context.l10n.snackbarFailedToEmbedLyrics; + error = result['error']?.toString() ?? l10nFailedToEmbedLyrics; } } else if (isMp3 || isOpus) { final metadata = _buildFallbackMetadata(); @@ -1845,7 +1850,7 @@ class _TrackMetadataScreenState extends ConsumerState { } if (ffmpegResult == null) { - error = context.l10n.snackbarFailedToEmbedLyrics; + error = l10nFailedToEmbedLyrics; } else if (_isSafFile) { final ok = await PlatformBridge.writeTempToSaf( ffmpegResult, @@ -1853,13 +1858,13 @@ class _TrackMetadataScreenState extends ConsumerState { ); success = ok; if (!ok) { - error = context.l10n.snackbarFailedToWriteStorage; + error = l10nFailedToWriteStorage; } } else { success = true; } } else { - error = context.l10n.snackbarUnsupportedAudioFormat; + error = l10nUnsupportedFormat; } if (mounted) { @@ -4299,7 +4304,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } best ??= results.first; - // Extract metadata from best match + // Extract basic metadata from search result final enriched = { 'title': (best['name'] ?? '').toString(), 'artist': (best['artists'] ?? best['artist'] ?? '').toString(), @@ -4311,39 +4316,82 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 'isrc': (best['isrc'] ?? '').toString(), }; - // Try to get extended metadata (genre, label, copyright) from Deezer + final needsIsrc = _autoFillFields.contains('isrc') && + enriched['isrc']!.isEmpty; + final needsExtended = _autoFillFields.contains('genre') || + _autoFillFields.contains('label') || + _autoFillFields.contains('copyright'); + final trackId = (best['spotify_id'] ?? best['id'] ?? '').toString(); - final source = (best['source'] ?? best['provider_id'] ?? '').toString(); + final source = + (best['source'] ?? best['provider_id'] ?? '').toString(); + final isDeezerSource = source.toLowerCase().contains('deezer'); - if ((_autoFillFields.contains('genre') || - _autoFillFields.contains('label') || - _autoFillFields.contains('copyright')) && - trackId.isNotEmpty) { + // Resolve Deezer track ID for extended metadata + ISRC + String? deezerId; + + if ((needsIsrc || needsExtended) && trackId.isNotEmpty) { try { - // If source is Deezer, fetch extended metadata directly - Map? extended; - if (source.toLowerCase().contains('deezer')) { - extended = await PlatformBridge.getDeezerExtendedMetadata(trackId); + if (isDeezerSource) { + // Source is Deezer — trackId is already a Deezer ID + deezerId = trackId; } else { - // Try ISRC lookup via Deezer for genre/label/copyright - final isrcForLookup = enriched['isrc'] ?? ''; - if (isrcForLookup.isNotEmpty) { - try { - final deezerResult = await PlatformBridge.searchDeezerByISRC( - isrcForLookup, - ); - final deezerTrackId = - (deezerResult['id'] ?? deezerResult['track_id'] ?? '') - .toString(); - if (deezerTrackId.isNotEmpty) { - extended = await PlatformBridge.getDeezerExtendedMetadata( - deezerTrackId, - ); - } - } catch (_) {} + // Source is Spotify/extension — convert to Deezer via SongLink + final deezerData = await PlatformBridge.convertSpotifyToDeezer( + 'track', + trackId, + ); + final trackData = deezerData['track']; + if (trackData is Map) { + final rawId = trackData['spotify_id'] as String?; + if (rawId != null && rawId.startsWith('deezer:')) { + deezerId = rawId.split(':')[1]; + } + // Also grab ISRC and release_date from the conversion response + final convIsrc = (trackData['isrc'] ?? '').toString().trim(); + if (convIsrc.isNotEmpty && enriched['isrc']!.isEmpty) { + enriched['isrc'] = convIsrc; + } + final convDate = + (trackData['release_date'] ?? '').toString().trim(); + if (convDate.isNotEmpty && enriched['date']!.isEmpty) { + enriched['date'] = convDate; + } } + // Fallback: legacy ID format + deezerId ??= (deezerData['id'] ?? '').toString(); + if (deezerId.isEmpty) deezerId = null; } + } catch (_) { + // SongLink conversion is best-effort + } + } + + if (!mounted) return; + + // Fetch ISRC from Deezer track metadata if still missing + if (needsIsrc && enriched['isrc']!.isEmpty && deezerId != null) { + try { + final deezerMeta = await PlatformBridge.getDeezerMetadata( + 'track', + deezerId, + ); + final deezerIsrc = (deezerMeta['isrc'] ?? '').toString().trim(); + if (deezerIsrc.isNotEmpty) { + enriched['isrc'] = deezerIsrc; + } + } catch (_) {} + } + + if (!mounted) return; + + // Fetch genre/label/copyright from Deezer extended metadata + if (needsExtended && deezerId != null) { + try { + final extended = await PlatformBridge.getDeezerExtendedMetadata( + deezerId, + ); if (extended != null) { enriched['genre'] = extended['genre'] ?? ''; enriched['label'] = extended['label'] ?? ''; @@ -4354,6 +4402,29 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } + // Fallback: if still no Deezer ID but we have ISRC, try ISRC lookup + if (needsExtended && + deezerId == null && + enriched['isrc']!.isNotEmpty) { + try { + final deezerResult = await PlatformBridge.searchDeezerByISRC( + enriched['isrc']!, + ); + final fallbackId = + (deezerResult['id'] ?? deezerResult['track_id'] ?? '') + .toString(); + if (fallbackId.isNotEmpty) { + final extended = + await PlatformBridge.getDeezerExtendedMetadata(fallbackId); + if (extended != null) { + enriched['genre'] = extended['genre'] ?? ''; + enriched['label'] = extended['label'] ?? ''; + enriched['copyright'] = extended['copyright'] ?? ''; + } + } + } catch (_) {} + } + if (!mounted) return; // Apply selected fields to controllers From e8327545ad5423f85ea6a950c34af09f0ed37adc Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 15 Mar 2026 21:11:36 +0700 Subject: [PATCH 15/34] feat: improve auto-fill track resolution in Edit Metadata sheet - Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search - Score-based match selection via _metadataMatchScore instead of provider order - Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet - Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers - Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult - Propagate copyright through Deezer enrichment chain (exports, extension_providers) --- go_backend/deezer.go | 13 +- go_backend/exports.go | 75 ++- go_backend/exports_deezer_metadata_test.go | 59 ++ go_backend/extension_providers.go | 17 +- lib/screens/track_metadata_screen.dart | 592 +++++++++++++++------ 5 files changed, 566 insertions(+), 190 deletions(-) create mode 100644 go_backend/exports_deezer_metadata_test.go diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 568e61c4..a389f3c8 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -256,6 +256,7 @@ type deezerAlbumFull struct { NbTracks int `json:"nb_tracks"` RecordType string `json:"record_type"` Label string `json:"label"` + Copyright string `json:"copyright"` Genres struct { Data []deezerGenre `json:"data"` } `json:"genres"` @@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { } type AlbumExtendedMetadata struct { - Genre string - Label string + Genre string + Label string + Copyright string } func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { @@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str } result := &AlbumExtendedMetadata{ - Genre: strings.Join(genres, ", "), - Label: album.Label, + Genre: strings.Join(genres, ", "), + Label: album.Label, + Copyright: album.Copyright, } c.cacheMu.Lock() @@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() - GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label) + GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright) return result, nil } diff --git a/go_backend/exports.go b/go_backend/exports.go index b8178d77..1c5f84ed 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -283,7 +283,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { return } - if req.ISRC == "" || (req.Genre != "" && req.Label != "") { + if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") { return } @@ -305,8 +305,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { if req.Label == "" && extMeta.Label != "" { req.Label = extMeta.Label } - if req.Genre != "" || req.Label != "" { - GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label) + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + } + if req.Genre != "" || req.Label != "" || req.Copyright != "" { + GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright) } } @@ -1335,10 +1338,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) { return "", err } - result := map[string]string{ - "genre": metadata.Genre, - "label": metadata.Label, - } + result := buildDeezerExtendedMetadataResult(metadata) jsonBytes, err := json.Marshal(result) if err != nil { @@ -1358,7 +1358,8 @@ func SearchDeezerByISRC(isrc string) (string, error) { return "", err } - jsonBytes, err := json.Marshal(track) + result := buildDeezerISRCSearchResult(track) + jsonBytes, err := json.Marshal(result) if err != nil { return "", err } @@ -1366,6 +1367,55 @@ func SearchDeezerByISRC(isrc string) (string, error) { return string(jsonBytes), nil } +func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string { + if metadata == nil { + return map[string]string{ + "genre": "", + "label": "", + "copyright": "", + } + } + + return map[string]string{ + "genre": metadata.Genre, + "label": metadata.Label, + "copyright": metadata.Copyright, + } +} + +func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} { + if track == nil { + return map[string]interface{}{} + } + + result := map[string]interface{}{ + "spotify_id": track.SpotifyID, + "artists": track.Artists, + "name": track.Name, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.Images, + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "external_urls": track.ExternalURL, + "isrc": track.ISRC, + "album_id": track.AlbumID, + "artist_id": track.ArtistID, + "album_type": track.AlbumType, + } + + if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" { + result["id"] = deezerID + result["track_id"] = deezerID + result["success"] = true + } + + return result +} + func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -1824,8 +1874,8 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr) } - // Try to get extended metadata (genre, label) from Deezer if not already set - if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") { + // Try to get extended metadata from Deezer if not already set + if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC) cancel() @@ -1836,7 +1886,10 @@ func ReEnrichFile(requestJSON string) (string, error) { if req.Label == "" && extMeta.Label != "" { req.Label = extMeta.Label } - GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label) + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + } + GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright) } } diff --git a/go_backend/exports_deezer_metadata_test.go b/go_backend/exports_deezer_metadata_test.go new file mode 100644 index 00000000..763965fc --- /dev/null +++ b/go_backend/exports_deezer_metadata_test.go @@ -0,0 +1,59 @@ +package gobackend + +import "testing" + +func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) { + result := buildDeezerExtendedMetadataResult(nil) + + if result["genre"] != "" { + t.Fatalf("expected empty genre, got %q", result["genre"]) + } + if result["label"] != "" { + t.Fatalf("expected empty label, got %q", result["label"]) + } + if result["copyright"] != "" { + t.Fatalf("expected empty copyright, got %q", result["copyright"]) + } +} + +func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) { + result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{ + Genre: "Rock", + Label: "EMI", + Copyright: "(C) Queen", + }) + + if result["genre"] != "Rock" { + t.Fatalf("unexpected genre: %q", result["genre"]) + } + if result["label"] != "EMI" { + t.Fatalf("unexpected label: %q", result["label"]) + } + if result["copyright"] != "(C) Queen" { + t.Fatalf("unexpected copyright: %q", result["copyright"]) + } +} + +func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) { + result := buildDeezerISRCSearchResult(&TrackMetadata{ + SpotifyID: "deezer:3135556", + Name: "Love Of My Life", + Artists: "Queen", + AlbumName: "A Night at the Opera", + ISRC: "GBUM71029604", + ReleaseDate: "1975-11-21", + }) + + if result["spotify_id"] != "deezer:3135556" { + t.Fatalf("unexpected spotify_id: %v", result["spotify_id"]) + } + if result["id"] != "3135556" { + t.Fatalf("unexpected id: %v", result["id"]) + } + if result["track_id"] != "3135556" { + t.Fatalf("unexpected track_id: %v", result["track_id"]) + } + if result["success"] != true { + t.Fatalf("expected success=true, got %v", result["success"]) + } +} diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 1866543f..b9f79bc4 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1065,8 +1065,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr) } - // Try Deezer extended metadata for genre/label if we have ISRC - if req.ISRC != "" && (req.Genre == "" || req.Label == "") { + // Try Deezer extended metadata if we have ISRC + if req.ISRC != "" && + (req.Genre == "" || req.Label == "" || req.Copyright == "") { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC) cancel() @@ -1077,7 +1078,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if req.Label == "" && extMeta.Label != "" { req.Label = extMeta.Label } - GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s\n", req.Genre, req.Label) + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + } + GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright) } } } @@ -1249,7 +1253,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) if isBuiltInProvider(providerIDNormalized) { - if (req.Genre == "" || req.Label == "") && req.ISRC != "" { + if (req.Genre == "" || req.Label == "" || req.Copyright == "") && + req.ISRC != "" { GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) deezerClient := GetDeezerClient() @@ -1264,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro req.Label = extMeta.Label GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label) } + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright) + } } else if err != nil { GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err) } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 5dde4282..85e803b6 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -307,11 +307,13 @@ class _TrackMetadataScreenState extends ConsumerState { ); // Fill in album name from file tags if stored value is empty - final needsAlbum = resolvedAlbum != null && + final needsAlbum = + resolvedAlbum != null && resolvedAlbum.isNotEmpty && (albumName.isEmpty); // Fill in duration from file if stored value is missing/zero - final needsDuration = resolvedDuration != null && + final needsDuration = + resolvedDuration != null && resolvedDuration > 0 && (duration == null || duration == 0); @@ -585,9 +587,9 @@ class _TrackMetadataScreenState extends ConsumerState { } void _showCueVirtualTrackSnackBar(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_cueVirtualTrackGuidance(context))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_cueVirtualTrackGuidance(context)))); } void _hideCurrentSnackBar() { @@ -606,17 +608,14 @@ class _TrackMetadataScreenState extends ConsumerState { } void _showSnackBarMessage(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } void _showLongSnackBarMessage(String message) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - duration: const Duration(seconds: 60), - ), + SnackBar(content: Text(message), duration: const Duration(seconds: 60)), ); } @@ -1144,9 +1143,7 @@ class _TrackMetadataScreenState extends ConsumerState { _copyToClipboard(context, webUrl); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.snackbarUrlCopied(serviceName), - ), + content: Text(context.l10n.snackbarUrlCopied(serviceName)), ), ); } @@ -1879,16 +1876,18 @@ class _TrackMetadataScreenState extends ConsumerState { } else { setState(() => _isEmbedding = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error ?? context.l10n.snackbarFailedToEmbedLyrics)), + SnackBar( + content: Text(error ?? context.l10n.snackbarFailedToEmbedLyrics), + ), ); } } } catch (e) { if (mounted) { setState(() => _isEmbedding = false); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } finally { if (coverPath != null) { @@ -2568,8 +2567,11 @@ class _TrackMetadataScreenState extends ConsumerState { onTap: () { _closeOptionsMenuAndRun( sheetContext, - () => - _showEditMetadataSheet(screenContext, ref, colorScheme), + () => _showEditMetadataSheet( + screenContext, + ref, + colorScheme, + ), ); }, ), @@ -3008,31 +3010,29 @@ class _TrackMetadataScreenState extends ConsumerState { const SizedBox(height: 16), Text( sheetContext.l10n.cueSplitTitle, - style: Theme.of(sheetContext).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(sheetContext).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), Text( sheetContext.l10n.cueSplitAlbum(album), - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 4), Text( sheetContext.l10n.cueSplitArtist(artist), - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 4), Text( sheetContext.l10n.cueSplitTrackCount(tracks.length), - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - ), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 16), // Track list preview (scrollable, max 200px) @@ -3259,8 +3259,12 @@ class _TrackMetadataScreenState extends ConsumerState { // Determine output directory final String outputDir; - final treeUri = !_isLocalItem ? (_downloadItem?.downloadTreeUri ?? '') : ''; - final relativeDir = !_isLocalItem ? (_downloadItem?.safRelativeDir ?? '') : ''; + final treeUri = !_isLocalItem + ? (_downloadItem?.downloadTreeUri ?? '') + : ''; + final relativeDir = !_isLocalItem + ? (_downloadItem?.safRelativeDir ?? '') + : ''; final writeBackToSaf = isSafSource && treeUri.isNotEmpty; if (writeBackToSaf) { final tempDir = await getTemporaryDirectory(); @@ -3326,9 +3330,7 @@ class _TrackMetadataScreenState extends ConsumerState { // Read existing metadata first final metadata = await PlatformBridge.readFileMetadata(path); if (metadata['error'] == null) { - final fields = { - 'cover_path': coverPath, - }; + final fields = {'cover_path': coverPath}; // Preserve existing fields for (final entry in metadata.entries) { if (entry.key == 'error' || entry.value == null) continue; @@ -3706,6 +3708,7 @@ class _TrackMetadataScreenState extends ConsumerState { colorScheme: colorScheme, initialValues: initialValues, filePath: cleanFilePath, + sourceTrackId: _spotifyId, ), ); @@ -3789,10 +3792,7 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - void _closeOptionsMenuAndRun( - BuildContext sheetContext, - VoidCallback action, - ) { + void _closeOptionsMenuAndRun(BuildContext sheetContext, VoidCallback action) { Navigator.pop(sheetContext); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -3919,15 +3919,24 @@ class _TrackMetadataScreenState extends ConsumerState { } } +class _ResolvedAutoFillTrack { + final Map track; + final String? deezerId; + + const _ResolvedAutoFillTrack({required this.track, this.deezerId}); +} + class _EditMetadataSheet extends StatefulWidget { final ColorScheme colorScheme; final Map initialValues; final String filePath; + final String? sourceTrackId; const _EditMetadataSheet({ required this.colorScheme, required this.initialValues, required this.filePath, + this.sourceTrackId, }); @override @@ -3935,6 +3944,12 @@ class _EditMetadataSheet extends StatefulWidget { } class _EditMetadataSheetState extends State<_EditMetadataSheet> { + static final RegExp _metadataCollapsePattern = RegExp(r'[^a-z0-9]+'); + static final RegExp _metadataWhitespacePattern = RegExp(r'\s+'); + static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$'); + static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$'); + bool _saving = false; bool _showAdvanced = false; bool _showAutoFill = false; @@ -4152,9 +4167,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } @@ -4243,6 +4258,251 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } + String _normalizeMetadataText(String value) { + final collapsed = value + .toLowerCase() + .replaceAll(_metadataCollapsePattern, ' ') + .trim(); + return collapsed.replaceAll(_metadataWhitespacePattern, ' '); + } + + bool _looksLikeIsrc(String value) { + return _isrcPattern.hasMatch(value.trim().toUpperCase()); + } + + String? _extractRawSpotifyTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_spotifyTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('spotify:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_spotifyTrackIdPattern.hasMatch(last)) { + return last; + } + return null; + } + + final uri = Uri.tryParse(raw); + if (uri != null && + uri.host.contains('spotify.com') && + uri.pathSegments.length >= 2 && + uri.pathSegments.first == 'track') { + final candidate = uri.pathSegments[1].trim(); + if (_spotifyTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + + return null; + } + + String? _extractRawDeezerTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_deezerTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('deezer:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_deezerTrackIdPattern.hasMatch(last)) { + return last; + } + } + + final uri = Uri.tryParse(raw); + if (uri != null && uri.host.contains('deezer.com')) { + final trackIndex = uri.pathSegments.indexOf('track'); + if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) { + final candidate = uri.pathSegments[trackIndex + 1].trim(); + if (_deezerTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + } + + return null; + } + + String? _extractRawSpotifyTrackId(Map track) { + for (final candidate in [track['spotify_id'], track['id']]) { + final spotifyId = _extractRawSpotifyTrackIdFromValue(candidate); + if (spotifyId != null) return spotifyId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final spotifyId = _extractRawSpotifyTrackIdFromValue( + externalLinks['spotify'], + ); + if (spotifyId != null) return spotifyId; + } + + return null; + } + + String? _extractRawDeezerTrackId(Map track) { + for (final candidate in [ + track['deezer_id'], + track['spotify_id'], + track['id'], + ]) { + final deezerId = _extractRawDeezerTrackIdFromValue(candidate); + if (deezerId != null) return deezerId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final deezerId = _extractRawDeezerTrackIdFromValue( + externalLinks['deezer'], + ); + if (deezerId != null) return deezerId; + } + + return null; + } + + Map _unwrapTrackPayload(Map payload) { + final track = payload['track']; + if (track is Map) { + return track; + } + return payload; + } + + void _mergeOnlineTrackData( + Map enriched, + Map track, + ) { + void put(String key, Object? value) { + final text = value?.toString().trim() ?? ''; + if (text.isNotEmpty && text != 'null') { + enriched[key] = text; + } + } + + put('title', track['name'] ?? track['title']); + put('artist', track['artists'] ?? track['artist']); + put('album', track['album_name'] ?? track['album']); + put('album_artist', track['album_artist']); + put('date', track['release_date']); + put('track_number', track['track_number']); + put('disc_number', track['disc_number']); + put('isrc', track['isrc']); + put('genre', track['genre']); + put('label', track['label']); + put('copyright', track['copyright']); + } + + Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers( + String currentIsrc, + ) async { + if (_looksLikeIsrc(currentIsrc)) { + final deezerTrack = await PlatformBridge.searchDeezerByISRC(currentIsrc); + return _ResolvedAutoFillTrack( + track: _unwrapTrackPayload(deezerTrack), + deezerId: _extractRawDeezerTrackId(deezerTrack), + ); + } + + final sourceTrackId = widget.sourceTrackId?.trim() ?? ''; + if (sourceTrackId.isEmpty) { + return null; + } + + final deezerId = _extractRawDeezerTrackIdFromValue(sourceTrackId); + if (deezerId != null) { + final deezerTrack = await PlatformBridge.getDeezerMetadata( + 'track', + deezerId, + ); + return _ResolvedAutoFillTrack( + track: _unwrapTrackPayload(deezerTrack), + deezerId: deezerId, + ); + } + + final spotifyId = _extractRawSpotifyTrackIdFromValue(sourceTrackId); + if (spotifyId != null) { + final deezerTrack = await PlatformBridge.convertSpotifyToDeezer( + 'track', + spotifyId, + ); + final track = _unwrapTrackPayload(deezerTrack); + return _ResolvedAutoFillTrack( + track: track, + deezerId: + _extractRawDeezerTrackId(track) ?? + _extractRawDeezerTrackId(deezerTrack), + ); + } + + return null; + } + + int _metadataMatchScore( + Map track, { + required String currentTitle, + required String currentArtist, + required String currentAlbum, + required String currentIsrc, + }) { + var score = 0; + + final candidateIsrc = (track['isrc']?.toString() ?? '') + .trim() + .toUpperCase(); + if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) { + score += 10000; + } + + final candidateTitle = _normalizeMetadataText( + (track['name'] ?? track['title'] ?? '').toString(), + ); + final candidateArtist = _normalizeMetadataText( + (track['artists'] ?? track['artist'] ?? '').toString(), + ); + final candidateAlbum = _normalizeMetadataText( + (track['album_name'] ?? track['album'] ?? '').toString(), + ); + + if (currentTitle.isNotEmpty && candidateTitle.isNotEmpty) { + if (candidateTitle == currentTitle) { + score += 400; + } else if (candidateTitle.contains(currentTitle) || + currentTitle.contains(candidateTitle)) { + score += 180; + } + } + + if (currentArtist.isNotEmpty && candidateArtist.isNotEmpty) { + if (candidateArtist == currentArtist) { + score += 320; + } else if (candidateArtist.contains(currentArtist) || + currentArtist.contains(candidateArtist)) { + score += 140; + } + } + + if (currentAlbum.isNotEmpty && candidateAlbum.isNotEmpty) { + if (candidateAlbum == currentAlbum) { + score += 120; + } else if (candidateAlbum.contains(currentAlbum) || + currentAlbum.contains(candidateAlbum)) { + score += 50; + } + } + + return score; + } + Future _fetchAndFill() async { if (_autoFillFields.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -4254,117 +4514,137 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { setState(() => _fetching = true); try { - // Build search query from current field values final title = _titleCtrl.text.trim(); final artist = _artistCtrl.text.trim(); final album = _albumCtrl.text.trim(); + final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); + Map? best; + String? deezerId; + + try { + final resolved = await _resolveAutoFillTrackFromIdentifiers( + currentIsrc, + ); + if (resolved != null) { + best = resolved.track; + deezerId = resolved.deezerId; + } + } catch (e) { + _log.w('Identifier-first autofill lookup failed: $e'); + } + final queryParts = []; if (title.isNotEmpty) queryParts.add(title); if (artist.isNotEmpty) queryParts.add(artist); - if (album.isNotEmpty) queryParts.add(album); + if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album); - if (queryParts.isEmpty) { + if (best == null && queryParts.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.editMetadataAutoFillNoResults), - ), + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), ); } return; } - final query = queryParts.join(' '); - final results = await PlatformBridge.searchTracksWithMetadataProviders( - query, - limit: 5, - ); + final normalizedTitle = _normalizeMetadataText(title); + final normalizedArtist = _normalizeMetadataText(artist); + final normalizedAlbum = _normalizeMetadataText(album); - if (!mounted) return; - - if (results.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + if (best == null) { + final query = queryParts.join(' '); + final results = await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 5, ); - return; - } - // Pick best match: prefer ISRC match, then first result - final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); - Map? best; - if (currentIsrc.isNotEmpty) { - for (final r in results) { - final candidateIsrc = - (r['isrc']?.toString() ?? '').trim().toUpperCase(); - if (candidateIsrc == currentIsrc) { - best = r; - break; + if (!mounted) return; + + if (results.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + ); + return; + } + + // Pick best match using current metadata, not only provider order. + best = results.first; + var bestScore = -1; + for (final result in results) { + final score = _metadataMatchScore( + result, + currentTitle: normalizedTitle, + currentArtist: normalizedArtist, + currentAlbum: normalizedAlbum, + currentIsrc: currentIsrc, + ); + if (score > bestScore) { + bestScore = score; + best = result; } } } - best ??= results.first; + + final selectedBest = best; + if (selectedBest == null) { + throw StateError('No metadata match resolved for auto-fill'); + } // Extract basic metadata from search result final enriched = { - 'title': (best['name'] ?? '').toString(), - 'artist': (best['artists'] ?? best['artist'] ?? '').toString(), - 'album': (best['album_name'] ?? best['album'] ?? '').toString(), - 'album_artist': (best['album_artist'] ?? '').toString(), - 'date': (best['release_date'] ?? '').toString(), - 'track_number': (best['track_number'] ?? '').toString(), - 'disc_number': (best['disc_number'] ?? '').toString(), - 'isrc': (best['isrc'] ?? '').toString(), + 'title': (selectedBest['name'] ?? '').toString(), + 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') + .toString(), + 'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '') + .toString(), + 'album_artist': (selectedBest['album_artist'] ?? '').toString(), + 'date': (selectedBest['release_date'] ?? '').toString(), + 'track_number': (selectedBest['track_number'] ?? '').toString(), + 'disc_number': (selectedBest['disc_number'] ?? '').toString(), + 'isrc': (selectedBest['isrc'] ?? '').toString(), }; + _mergeOnlineTrackData(enriched, selectedBest); - final needsIsrc = _autoFillFields.contains('isrc') && - enriched['isrc']!.isEmpty; - final needsExtended = _autoFillFields.contains('genre') || + final needsIsrc = + _autoFillFields.contains('isrc') && enriched['isrc']!.isEmpty; + final needsExtended = + _autoFillFields.contains('genre') || _autoFillFields.contains('label') || _autoFillFields.contains('copyright'); - final trackId = - (best['spotify_id'] ?? best['id'] ?? '').toString(); - final source = - (best['source'] ?? best['provider_id'] ?? '').toString(); - final isDeezerSource = source.toLowerCase().contains('deezer'); + final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest); - // Resolve Deezer track ID for extended metadata + ISRC - String? deezerId; + deezerId ??= _extractRawDeezerTrackId(selectedBest); + final candidateIsrc = enriched['isrc']!.trim().toUpperCase(); + final deezerLookupIsrc = _looksLikeIsrc(currentIsrc) + ? currentIsrc + : (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : ''); - if ((needsIsrc || needsExtended) && trackId.isNotEmpty) { + if (needsIsrc || needsExtended) { try { - if (isDeezerSource) { - // Source is Deezer — trackId is already a Deezer ID - deezerId = trackId; - } else { - // Source is Spotify/extension — convert to Deezer via SongLink + if (deezerId == null && deezerLookupIsrc.isNotEmpty) { + final deezerResult = await PlatformBridge.searchDeezerByISRC( + deezerLookupIsrc, + ); + deezerId = _extractRawDeezerTrackId(deezerResult); + _mergeOnlineTrackData(enriched, deezerResult); + } + + if (deezerId == null && rawSpotifyId != null) { + // Spotify IDs can be mapped through SongLink to a Deezer track. final deezerData = await PlatformBridge.convertSpotifyToDeezer( 'track', - trackId, + rawSpotifyId, ); final trackData = deezerData['track']; if (trackData is Map) { - final rawId = trackData['spotify_id'] as String?; - if (rawId != null && rawId.startsWith('deezer:')) { - deezerId = rawId.split(':')[1]; - } - // Also grab ISRC and release_date from the conversion response - final convIsrc = (trackData['isrc'] ?? '').toString().trim(); - if (convIsrc.isNotEmpty && enriched['isrc']!.isEmpty) { - enriched['isrc'] = convIsrc; - } - final convDate = - (trackData['release_date'] ?? '').toString().trim(); - if (convDate.isNotEmpty && enriched['date']!.isEmpty) { - enriched['date'] = convDate; - } + deezerId = _extractRawDeezerTrackId(trackData); + _mergeOnlineTrackData(enriched, trackData); } - // Fallback: legacy ID format - deezerId ??= (deezerData['id'] ?? '').toString(); - if (deezerId.isEmpty) deezerId = null; + deezerId ??= _extractRawDeezerTrackId(deezerData); } } catch (_) { - // SongLink conversion is best-effort + // Deezer resolution is best-effort } } @@ -4377,7 +4657,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 'track', deezerId, ); - final deezerIsrc = (deezerMeta['isrc'] ?? '').toString().trim(); + final trackData = _unwrapTrackPayload(deezerMeta); + _mergeOnlineTrackData(enriched, trackData); + final deezerIsrc = (trackData['isrc'] ?? '').toString().trim(); if (deezerIsrc.isNotEmpty) { enriched['isrc'] = deezerIsrc; } @@ -4402,29 +4684,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } - // Fallback: if still no Deezer ID but we have ISRC, try ISRC lookup - if (needsExtended && - deezerId == null && - enriched['isrc']!.isNotEmpty) { - try { - final deezerResult = await PlatformBridge.searchDeezerByISRC( - enriched['isrc']!, - ); - final fallbackId = - (deezerResult['id'] ?? deezerResult['track_id'] ?? '') - .toString(); - if (fallbackId.isNotEmpty) { - final extended = - await PlatformBridge.getDeezerExtendedMetadata(fallbackId); - if (extended != null) { - enriched['genre'] = extended['genre'] ?? ''; - enriched['label'] = extended['label'] ?? ''; - enriched['copyright'] = extended['copyright'] ?? ''; - } - } - } catch (_) {} - } - if (!mounted) return; // Apply selected fields to controllers @@ -4432,7 +4691,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { for (final key in _autoFillFields) { if (key == 'cover') continue; // Handle cover separately below final value = enriched[key]; - if (value != null && value.isNotEmpty && value != '0' && value != 'null') { + if (value != null && + value.isNotEmpty && + value != '0' && + value != 'null') { final ctrl = _controllerForKey(key); if (ctrl != null) { ctrl.text = value; @@ -4444,7 +4706,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { // Handle cover art download if (_autoFillFields.contains('cover')) { final coverUrl = - (best['cover_url'] ?? best['images'] ?? '').toString(); + (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') + .toString(); if (coverUrl.isNotEmpty) { try { final tempDir = await Directory.systemTemp.createTemp( @@ -4494,9 +4757,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarError(e.toString())), - ), + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), ); } } finally { @@ -4722,9 +4983,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } finally { if (mounted) setState(() => _saving = false); @@ -4882,11 +5143,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), child: Row( children: [ - Icon( - Icons.travel_explore, - size: 20, - color: cs.primary, - ), + Icon(Icons.travel_explore, size: 20, color: cs.primary), const SizedBox(width: 8), Expanded( child: Text( @@ -4911,9 +5168,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( context.l10n.editMetadataAutoFillDesc, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: cs.onSurfaceVariant, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), ), ), const SizedBox(height: 8), @@ -4976,18 +5233,13 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { const SizedBox(height: 10), // Fetch button Padding( - padding: const EdgeInsets.only( - left: 12, - right: 12, - bottom: 12, - ), + padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), child: SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: - (_fetching || _saving || _autoFillFields.isEmpty) - ? null - : _fetchAndFill, + onPressed: (_fetching || _saving || _autoFillFields.isEmpty) + ? null + : _fetchAndFill, icon: _fetching ? const SizedBox( width: 16, @@ -5029,9 +5281,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), child: Text( label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: cs.primary, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: cs.primary), ), ), ); From 2b23678c0d98254b097d391662d60d17f4a6e0b3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 02:13:45 +0700 Subject: [PATCH 16/34] feat: add FLAC/ALAC bidirectional lossless conversion support - Add _convertToAlac() and _convertToFlac() in ffmpeg_service with single-pass FFmpeg encoding, metadata tags, and cover art embedding - Wire lossless formats (ALAC, FLAC) into single-track convert sheet with dynamic format list based on source format, hidden bitrate for lossless targets, and lossless hint text - Add lossless conversion to batch convert UI in downloaded_album, local_album, and queue_tab screens with lossy-source filtering - Fix M4A quality probe in Go backend: increase audio sample entry buffer from 24 to 32 bytes, read sample rate from correct offset (bytes 28-29) and bit depth from samplesize field (bytes 22-23) - Add l10n keys for lossless confirm dialogs and hints (en, id) --- go_backend/metadata.go | 25 ++- lib/l10n/app_localizations.dart | 23 ++- lib/l10n/app_localizations_de.dart | 23 +++ lib/l10n/app_localizations_en.dart | 26 ++- lib/l10n/app_localizations_es.dart | 26 ++- lib/l10n/app_localizations_fr.dart | 23 +++ lib/l10n/app_localizations_hi.dart | 23 +++ lib/l10n/app_localizations_id.dart | 26 ++- lib/l10n/app_localizations_ja.dart | 23 +++ lib/l10n/app_localizations_ko.dart | 23 +++ lib/l10n/app_localizations_nl.dart | 23 +++ lib/l10n/app_localizations_pt.dart | 26 ++- lib/l10n/app_localizations_ru.dart | 23 +++ lib/l10n/app_localizations_tr.dart | 23 +++ lib/l10n/app_localizations_zh.dart | 26 ++- lib/l10n/arb/app_en.arb | 30 +++- lib/l10n/arb/app_id.arb | 18 +- lib/screens/downloaded_album_screen.dart | 152 ++++++++++------ lib/screens/local_album_screen.dart | 150 ++++++++++------ lib/screens/queue_tab.dart | 154 ++++++++++------ lib/screens/track_metadata_screen.dart | 174 ++++++++++++------ lib/services/ffmpeg_service.dart | 215 ++++++++++++++++++++++- 22 files changed, 1037 insertions(+), 218 deletions(-) diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 29ab2d02..391b7e5d 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -743,15 +743,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) { return AudioQuality{}, err } - buf := make([]byte, 24) + buf := make([]byte, 32) if _, err := f.ReadAt(buf, sampleOffset); err != nil { return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err) } - sampleRate := int(buf[22])<<8 | int(buf[23]) - bitDepth := 16 - if atomType == "alac" { - bitDepth = 24 + // AudioSampleEntry layout from the box type field: + // [0:4] type ("mp4a"/"alac") + // [4:10] SampleEntry.reserved + // [10:12] data_reference_index + // [12:20] reserved[8] + // [20:22] channelcount + // [22:24] samplesize (bit depth) + // [24:26] pre_defined + // [26:28] reserved + // [28:32] samplerate (16.16 fixed-point) + sampleRate := int(buf[28])<<8 | int(buf[29]) + bitDepth := int(buf[22])<<8 | int(buf[23]) + if bitDepth <= 0 { + bitDepth = 16 + if atomType == "alac" { + bitDepth = 24 + } } return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil @@ -874,7 +887,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string if bestIdx >= 0 { absolute := readPos - int64(len(tail)) + int64(bestIdx) - if absolute+24 > fileSize { + if absolute+32 > fileSize { return 0, "", fmt.Errorf("audio info not found in M4A file") } return absolute, bestType, nil diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 01af801f..15451886 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3883,7 +3883,7 @@ abstract class AppLocalizations { /// Subtitle for convert format menu item /// /// In en, this message translates to: - /// **'Convert to MP3 or Opus'** + /// **'Convert to MP3, Opus, ALAC, or FLAC'** String get trackConvertFormatSubtitle; /// Title of convert bottom sheet @@ -3920,6 +3920,21 @@ abstract class AppLocalizations { String bitrate, ); + /// Confirmation dialog message for lossless-to-lossless conversion + /// + /// In en, this message translates to: + /// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'** + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ); + + /// Hint shown when converting between lossless formats + /// + /// In en, this message translates to: + /// **'Lossless conversion — no quality loss'** + String get trackConvertLosslessHint; + /// Snackbar while converting /// /// In en, this message translates to: @@ -4290,6 +4305,12 @@ abstract class AppLocalizations { String bitrate, ); + /// Confirmation dialog message for lossless batch conversion + /// + /// In en, this message translates to: + /// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'** + String selectionBatchConvertConfirmMessageLossless(int count, String format); + /// Snackbar during batch conversion progress /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c3ee5083..f0137de1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2226,6 +2226,18 @@ class AppLocalizationsDe extends AppLocalizations { return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Konvertiere Audio...'; @@ -2480,6 +2492,17 @@ class AppLocalizationsDe extends AppLocalizations { return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Konvertiere $current von $total...'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 180810f3..041c5bd1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2176,7 +2176,8 @@ class AppLocalizationsEn extends AppLocalizations { String get trackConvertFormat => 'Convert Format'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => + 'Convert to MP3, Opus, ALAC, or FLAC'; @override String get trackConvertTitle => 'Convert Audio'; @@ -2199,6 +2200,18 @@ class AppLocalizationsEn extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2452,6 +2465,17 @@ class AppLocalizationsEn extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index cea3915b..99849885 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2176,7 +2176,8 @@ class AppLocalizationsEs extends AppLocalizations { String get trackConvertFormat => 'Convert Format'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => + 'Convert to MP3, Opus, ALAC, or FLAC'; @override String get trackConvertTitle => 'Convert Audio'; @@ -2199,6 +2200,18 @@ class AppLocalizationsEs extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2452,6 +2465,17 @@ class AppLocalizationsEs extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 8270c7c2..cec6eae9 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2201,6 +2201,18 @@ class AppLocalizationsFr extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2454,6 +2466,17 @@ class AppLocalizationsFr extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 1767c438..9a9ad518 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2199,6 +2199,18 @@ class AppLocalizationsHi extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2452,6 +2464,17 @@ class AppLocalizationsHi extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 67a9c2fc..902d49cd 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2183,7 +2183,8 @@ class AppLocalizationsId extends AppLocalizations { String get trackConvertFormat => 'Convert Format'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => + 'Konversi ke MP3, Opus, ALAC, atau FLAC'; @override String get trackConvertTitle => 'Convert Audio'; @@ -2206,6 +2207,18 @@ class AppLocalizationsId extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.'; + } + + @override + String get trackConvertLosslessHint => + 'Konversi lossless — tanpa kehilangan kualitas'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2459,6 +2472,17 @@ class AppLocalizationsId extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index e406ba35..31270670 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2186,6 +2186,18 @@ class AppLocalizationsJa extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'オーディオを変換中...'; @@ -2439,6 +2451,17 @@ class AppLocalizationsJa extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index b8085a70..76f1b737 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2179,6 +2179,18 @@ class AppLocalizationsKo extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2432,6 +2444,17 @@ class AppLocalizationsKo extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index c54e044a..d509e1e7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2199,6 +2199,18 @@ class AppLocalizationsNl extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2452,6 +2464,17 @@ class AppLocalizationsNl extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ac0f577d..ef87eca7 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2176,7 +2176,8 @@ class AppLocalizationsPt extends AppLocalizations { String get trackConvertFormat => 'Convert Format'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => + 'Convert to MP3, Opus, ALAC, or FLAC'; @override String get trackConvertTitle => 'Convert Audio'; @@ -2199,6 +2200,18 @@ class AppLocalizationsPt extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2452,6 +2465,17 @@ class AppLocalizationsPt extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 76814d38..8bbb8a74 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2252,6 +2252,18 @@ class AppLocalizationsRu extends AppLocalizations { return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Конвертация аудио...'; @@ -2511,6 +2523,17 @@ class AppLocalizationsRu extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Конвертация $current из $total...'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index f24d9f88..bbf6ef14 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2211,6 +2211,18 @@ class AppLocalizationsTr extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2464,6 +2476,17 @@ class AppLocalizationsTr extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d9440cff..465f196c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2176,7 +2176,8 @@ class AppLocalizationsZh extends AppLocalizations { String get trackConvertFormat => 'Convert Format'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => + 'Convert to MP3, Opus, ALAC, or FLAC'; @override String get trackConvertTitle => 'Convert Audio'; @@ -2199,6 +2200,18 @@ class AppLocalizationsZh extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmMessageLossless( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertLosslessHint => + 'Lossless conversion — no quality loss'; + @override String get trackConvertConverting => 'Converting audio...'; @@ -2452,6 +2465,17 @@ class AppLocalizationsZh extends AppLocalizations { return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmMessageLossless(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; + } + @override String selectionBatchConvertProgress(int current, int total) { return 'Converting $current of $total...'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6da406d0..7f4e1acb 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2873,7 +2873,7 @@ "@trackConvertFormat": { "description": "Menu item - convert audio format" }, - "trackConvertFormatSubtitle": "Convert to MP3 or Opus", + "trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC", "@trackConvertFormatSubtitle": { "description": "Subtitle for convert format menu item" }, @@ -2908,6 +2908,22 @@ } } }, + "trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.", + "@trackConvertConfirmMessageLossless": { + "description": "Confirmation dialog message for lossless-to-lossless conversion", + "placeholders": { + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + } + } + }, + "trackConvertLosslessHint": "Lossless conversion — no quality loss", + "@trackConvertLosslessHint": { + "description": "Hint shown when converting between lossless formats" + }, "trackConvertConverting": "Converting audio...", "@trackConvertConverting": { "description": "Snackbar while converting" @@ -3259,6 +3275,18 @@ } } }, + "selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.", + "@selectionBatchConvertConfirmMessageLossless": { + "description": "Confirmation dialog message for lossless batch conversion", + "placeholders": { + "count": { + "type": "int" + }, + "format": { + "type": "String" + } + } + }, "selectionBatchConvertProgress": "Converting {current} of {total}...", "@selectionBatchConvertProgress": { "description": "Snackbar during batch conversion progress", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index bab66bf2..ff764ec8 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -2809,7 +2809,7 @@ "@trackConvertFormat": { "description": "Menu item - convert audio format" }, - "trackConvertFormatSubtitle": "Convert to MP3 or Opus", + "trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC", "@trackConvertFormatSubtitle": { "description": "Subtitle for convert format menu item" }, @@ -2844,6 +2844,22 @@ } } }, + "trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.", + "@trackConvertConfirmMessageLossless": { + "description": "Confirmation dialog message for lossless-to-lossless conversion", + "placeholders": { + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + } + } + }, + "trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas", + "@trackConvertLosslessHint": { + "description": "Hint shown when converting between lossless formats" + }, "trackConvertConverting": "Converting audio...", "@trackConvertConverting": { "description": "Snackbar while converting" diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 55b1b71c..f320aa40 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -912,6 +912,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ) { String selectedFormat = 'MP3'; String selectedBitrate = '320k'; + bool isLosslessTarget = false; showModalBottomSheet( context: context, @@ -923,7 +924,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['MP3', 'Opus']; + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( @@ -960,51 +961,75 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + } + }); } }, ); }).toList(), ), + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + if (isLosslessTarget) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1057,12 +1082,19 @@ class _DownloadedAlbumScreenState extends ConsumerState { : item.filePath.toLowerCase(); final ext = nameToCheck.endsWith('.flac') ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' : nameToCheck.endsWith('.mp3') ? 'MP3' : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' : null; - if (ext != null && ext != targetFormat) selected.add(item); + if (ext == null || ext == targetFormat) continue; + // Skip lossy sources when target is lossless (pointless re-encoding) + final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; + if (isLosslessTarget && !isLosslessSource) continue; + selected.add(item); } if (selected.isEmpty) { @@ -1074,16 +1106,22 @@ class _DownloadedAlbumScreenState extends ConsumerState { return; } + final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - context.l10n.selectionBatchConvertConfirmMessage( - selected.length, - targetFormat, - bitrate, - ), + isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selected.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selected.length, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -1103,8 +1141,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { int successCount = 0; final total = selected.length; final historyDb = HistoryDatabase.instance; - final newQuality = - '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final newQuality = (targetFormat.toUpperCase() == 'ALAC' || + targetFormat.toUpperCase() == 'FLAC') + ? '${targetFormat.toUpperCase()} Lossless' + : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; @@ -1207,13 +1247,27 @@ class _DownloadedAlbumScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' - ? '.opus' - : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 122a4bab..1b662324 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1131,6 +1131,7 @@ class _LocalAlbumScreenState extends ConsumerState { ) { String selectedFormat = 'MP3'; String selectedBitrate = '320k'; + bool isLosslessTarget = false; showModalBottomSheet( context: context, @@ -1142,7 +1143,7 @@ class _LocalAlbumScreenState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['MP3', 'Opus']; + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( @@ -1179,51 +1180,75 @@ class _LocalAlbumScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + } + }); } }, ); }).toList(), ), + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + if (isLosslessTarget) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1276,6 +1301,8 @@ class _LocalAlbumScreenState extends ConsumerState { final fmt = item.format!.toLowerCase(); if (fmt == 'flac') { currentFormat = 'FLAC'; + } else if (fmt == 'm4a') { + currentFormat = 'M4A'; } else if (fmt == 'mp3') { currentFormat = 'MP3'; } else if (fmt == 'opus' || fmt == 'ogg') { @@ -1287,15 +1314,20 @@ class _LocalAlbumScreenState extends ConsumerState { final lower = item.filePath.toLowerCase(); if (lower.endsWith('.flac')) { currentFormat = 'FLAC'; + } else if (lower.endsWith('.m4a')) { + currentFormat = 'M4A'; } else if (lower.endsWith('.mp3')) { currentFormat = 'MP3'; } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { currentFormat = 'Opus'; } } - if (currentFormat != null && currentFormat != targetFormat) { - selected.add(item); - } + if (currentFormat == null || currentFormat == targetFormat) continue; + // Skip lossy sources when target is lossless (pointless re-encoding) + final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; + if (isLosslessTarget && !isLosslessSource) continue; + selected.add(item); } if (selected.isEmpty) { @@ -1307,16 +1339,22 @@ class _LocalAlbumScreenState extends ConsumerState { return; } + final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - context.l10n.selectionBatchConvertConfirmMessage( - selected.length, - targetFormat, - bitrate, - ), + isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selected.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selected.length, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -1481,13 +1519,27 @@ class _LocalAlbumScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' - ? '.opus' - : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 52ca3309..64f9c0e3 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4757,6 +4757,7 @@ class _QueueTabState extends ConsumerState { ) async { String selectedFormat = 'MP3'; String selectedBitrate = '320k'; + bool isLosslessTarget = false; var didStartConversion = false; _hideSelectionOverlay(); @@ -4772,7 +4773,7 @@ class _QueueTabState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['MP3', 'Opus']; + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( @@ -4809,51 +4810,75 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + } + }); } }, ); }).toList(), ), + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + if (isLosslessTarget) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -4929,14 +4954,19 @@ class _QueueTabState extends ConsumerState { } final ext = nameToCheck.endsWith('.flac') ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' : nameToCheck.endsWith('.mp3') ? 'MP3' : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' : null; - if (ext != null && ext != targetFormat) { - selectedItems.add(item); - } + if (ext == null || ext == targetFormat) continue; + // Skip lossy sources when target is lossless (pointless re-encoding) + final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; + if (isLosslessTarget && !isLosslessSource) continue; + selectedItems.add(item); } if (selectedItems.isEmpty) { @@ -4949,16 +4979,22 @@ class _QueueTabState extends ConsumerState { } // Confirm + final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - context.l10n.selectionBatchConvertConfirmMessage( - selectedItems.length, - targetFormat, - bitrate, - ), + isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selectedItems.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selectedItems.length, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -4978,8 +5014,10 @@ class _QueueTabState extends ConsumerState { int successCount = 0; final total = selectedItems.length; final historyDb = HistoryDatabase.instance; - final newQuality = - '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final newQuality = (targetFormat.toUpperCase() == 'ALAC' || + targetFormat.toUpperCase() == 'FLAC') + ? '${targetFormat.toUpperCase()} Lossless' + : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; @@ -5093,13 +5131,27 @@ class _QueueTabState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' - ? '.opus' - : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 85e803b6..744acf54 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -2662,6 +2662,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool get _isConvertibleFormat { final lower = cleanFilePath.toLowerCase(); return lower.endsWith('.flac') || + lower.endsWith('.m4a') || lower.endsWith('.mp3') || lower.endsWith('.opus') || lower.endsWith('.ogg'); @@ -2692,6 +2693,7 @@ class _TrackMetadataScreenState extends ConsumerState { } final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.flac')) return 'FLAC'; + if (lower.endsWith('.m4a')) return 'M4A'; if (lower.endsWith('.mp3')) return 'MP3'; if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; if (lower.endsWith('.cue')) return 'CUE'; @@ -2749,8 +2751,12 @@ class _TrackMetadataScreenState extends ConsumerState { } String _buildConvertedQualityLabel(String targetFormat, String bitrate) { + final upper = targetFormat.toUpperCase(); + if (upper == 'ALAC' || upper == 'FLAC') { + return '$upper Lossless'; + } final normalizedBitrate = bitrate.trim().toLowerCase(); - return '${targetFormat.toUpperCase()} $normalizedBitrate'; + return '$upper $normalizedBitrate'; } String? _extractLossyBitrateLabel(String? quality) { @@ -2790,17 +2796,27 @@ class _TrackMetadataScreenState extends ConsumerState { void _showConvertSheet(BuildContext context) { final currentFormat = _currentFileFormat; - // Available target formats (exclude current) - final formats = [ - 'MP3', - 'Opus', - ].where((f) => f != currentFormat).toList(); + final isLosslessSource = + currentFormat == 'FLAC' || currentFormat == 'M4A'; + + // Build available target formats based on source + final formats = []; if (currentFormat == 'FLAC') { - // FLAC can convert to both + formats.addAll(['ALAC', 'MP3', 'Opus']); + } else if (currentFormat == 'M4A') { + formats.addAll(['FLAC', 'MP3', 'Opus']); + } else if (currentFormat == 'MP3') { + formats.add('Opus'); + } else if (currentFormat == 'Opus') { + formats.add('MP3'); + } else { + formats.addAll(['MP3', 'Opus']); } String selectedFormat = formats.first; String selectedBitrate = selectedFormat == 'Opus' ? '128k' : '320k'; + bool isLosslessTarget = + selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; showModalBottomSheet( context: context, @@ -2849,53 +2865,79 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - // Reset bitrate to default for format - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + } + }); } }, ); }).toList(), ), + + // Only show bitrate for lossy targets + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + + // Show lossless indicator + if (isLosslessTarget && isLosslessSource) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( @@ -2917,7 +2959,9 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), child: Text( - '$currentFormat -> $selectedFormat @ $selectedBitrate', + isLosslessTarget + ? '$currentFormat -> $selectedFormat (Lossless)' + : '$currentFormat -> $selectedFormat @ $selectedBitrate', ), ), ), @@ -3402,17 +3446,25 @@ class _TrackMetadataScreenState extends ConsumerState { required String targetFormat, required String bitrate, }) { + final isLossless = + targetFormat.toUpperCase() == 'ALAC' || + targetFormat.toUpperCase() == 'FLAC'; showDialog( context: context, builder: (dialogContext) { return AlertDialog( title: Text(dialogContext.l10n.trackConvertConfirmTitle), content: Text( - dialogContext.l10n.trackConvertConfirmMessage( - sourceFormat, - targetFormat, - bitrate, - ), + isLossless + ? dialogContext.l10n.trackConvertConfirmMessageLossless( + sourceFormat, + targetFormat, + ) + : dialogContext.l10n.trackConvertConfirmMessage( + sourceFormat, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -3561,11 +3613,27 @@ class _TrackMetadataScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: // mp3 + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index b9aa6603..cb533cb0 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1209,7 +1209,8 @@ class FFmpegService { } /// Unified audio format conversion with full metadata + cover preservation. - /// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format). + /// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC. + /// ALAC and FLAC targets are lossless (bitrate parameter is ignored). /// Returns the new file path on success, null on failure. static Future convertAudioFormat({ required String inputPath, @@ -1220,11 +1221,30 @@ class FFmpegService { bool deleteOriginal = true, }) async { final format = targetFormat.toLowerCase(); - if (format != 'mp3' && format != 'opus') { + if (!const {'mp3', 'opus', 'alac', 'flac'}.contains(format)) { _log.e('Unsupported target format: $targetFormat'); return null; } + // Lossless targets: dedicated single-pass methods + if (format == 'alac') { + return _convertToAlac( + inputPath: inputPath, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: deleteOriginal, + ); + } + if (format == 'flac') { + return _convertToFlac( + inputPath: inputPath, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: deleteOriginal, + ); + } + + // Lossy targets: MP3 / Opus final extension = format == 'opus' ? '.opus' : '.mp3'; final outputPath = _buildOutputPath(inputPath, extension); @@ -1296,6 +1316,197 @@ class FFmpegService { return outputPath; } + /// Convert any audio format to ALAC (Apple Lossless) in an M4A container. + /// Metadata and cover art are embedded in a single FFmpeg pass. + static Future _convertToAlac({ + required String inputPath, + required Map metadata, + String? coverPath, + bool deleteOriginal = true, + }) async { + final outputPath = _buildOutputPath(inputPath, '.m4a'); + + final cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$inputPath" '); + + // Cover art as second input for M4A attached picture + final hasCover = coverPath != null && + coverPath.trim().isNotEmpty && + await File(coverPath).exists(); + if (hasCover) { + cmdBuffer.write('-i "$coverPath" '); + } + + cmdBuffer.write('-map 0:a '); + if (hasCover) { + cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic '); + } + cmdBuffer.write('-c:a alac '); + cmdBuffer.write('-map_metadata -1 '); + + // Embed M4A metadata tags + final m4aTags = _convertToM4aTags(metadata); + for (final entry in m4aTags.entries) { + final sanitized = entry.value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); + } + + cmdBuffer.write('"$outputPath" -y'); + + _log.i( + 'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC', + ); + final result = await _execute(cmdBuffer.toString()); + + if (!result.success) { + _log.e('ALAC conversion failed: ${result.output}'); + return null; + } + + if (deleteOriginal) { + try { + await File(inputPath).delete(); + _log.i( + 'Deleted original: ${inputPath.split(Platform.pathSeparator).last}', + ); + } catch (e) { + _log.w('Failed to delete original: $e'); + } + } + + return outputPath; + } + + /// Convert any audio format to FLAC. + /// Metadata (Vorbis comments) and cover art (METADATA_BLOCK_PICTURE) are + /// embedded in a single FFmpeg pass. + static Future _convertToFlac({ + required String inputPath, + required Map metadata, + String? coverPath, + bool deleteOriginal = true, + }) async { + final outputPath = _buildOutputPath(inputPath, '.flac'); + + final cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$inputPath" '); + cmdBuffer.write('-map 0:a '); + cmdBuffer.write('-c:a flac -compression_level 8 '); + cmdBuffer.write('-map_metadata -1 '); + + // Embed Vorbis comments + for (final entry in metadata.entries) { + if (entry.value.trim().isEmpty) continue; + final sanitized = entry.value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); + } + + // Embed cover art via METADATA_BLOCK_PICTURE (same approach as Opus) + if (coverPath != null && coverPath.trim().isNotEmpty) { + try { + if (await File(coverPath).exists()) { + final pictureBlock = await _createMetadataBlockPicture(coverPath); + if (pictureBlock != null) { + final escapedBlock = pictureBlock.replaceAll('"', '\\"'); + cmdBuffer.write( + '-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ', + ); + _log.d( + 'Created METADATA_BLOCK_PICTURE for FLAC (${pictureBlock.length} chars)', + ); + } + } + } catch (e) { + _log.e('Error creating METADATA_BLOCK_PICTURE for FLAC: $e'); + } + } + + cmdBuffer.write('"$outputPath" -y'); + + _log.i( + 'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC', + ); + final result = await _execute(cmdBuffer.toString()); + + if (!result.success) { + _log.e('FLAC conversion failed: ${result.output}'); + return null; + } + + if (deleteOriginal) { + try { + await File(inputPath).delete(); + _log.i( + 'Deleted original: ${inputPath.split(Platform.pathSeparator).last}', + ); + } catch (e) { + _log.w('Failed to delete original: $e'); + } + } + + return outputPath; + } + + /// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg. + static Map _convertToM4aTags( + Map metadata, + ) { + final m4aMap = {}; + + for (final entry in metadata.entries) { + final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + final value = entry.value; + if (value.trim().isEmpty) continue; + + switch (key) { + case 'TITLE': + m4aMap['title'] = value; + break; + case 'ARTIST': + m4aMap['artist'] = value; + break; + case 'ALBUM': + m4aMap['album'] = value; + break; + case 'ALBUMARTIST': + m4aMap['album_artist'] = value; + break; + case 'TRACKNUMBER': + case 'TRACK': + case 'TRCK': + m4aMap['track'] = value; + break; + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + m4aMap['disc'] = value; + break; + case 'DATE': + case 'YEAR': + m4aMap['date'] = value; + break; + case 'GENRE': + m4aMap['genre'] = value; + break; + case 'COMPOSER': + m4aMap['composer'] = value; + break; + case 'COMMENT': + m4aMap['comment'] = value; + break; + case 'COPYRIGHT': + m4aMap['copyright'] = value; + break; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + m4aMap['lyrics'] = value; + break; + } + } + + return m4aMap; + } + static Map _convertToId3Tags( Map vorbisMetadata, ) { From 47bd24c1bd91494063f966b99a49c4d847e6841a Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 02:26:53 +0700 Subject: [PATCH 17/34] fix: preserve metadata and cover art in ALAC/M4A to FLAC conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use -map_metadata 0 instead of -map_metadata -1 so FFmpeg copies and auto-remaps source tags (M4A/ID3 → Vorbis comments) as a base - Add _normalizeToVorbisComments() to filter technical fields (BIT_DEPTH, SAMPLE_RATE, DURATION) and normalize key variations to standard Vorbis comment names before applying overrides - Switch cover art embedding from METADATA_BLOCK_PICTURE base64 (unreliable on Android due to command-line length limits) to -i cover -map 1:v -disposition attached_pic (same proven approach as embedMetadata and _convertToAlac) - Drop zero-value track/disc numbers from override map to prevent clobbering source metadata with '0' from Go readFileMetadata --- lib/services/ffmpeg_service.dart | 129 ++++++++++++++++++++++++------- 1 file changed, 101 insertions(+), 28 deletions(-) diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index cb533cb0..dfb88847 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1378,8 +1378,11 @@ class FFmpegService { } /// Convert any audio format to FLAC. - /// Metadata (Vorbis comments) and cover art (METADATA_BLOCK_PICTURE) are - /// embedded in a single FFmpeg pass. + /// Source metadata is preserved via -map_metadata 0 (FFmpeg auto-remaps + /// tag names between container formats), then explicit Vorbis comment + /// overrides are applied from the [metadata] map. + /// Cover art is embedded via a second input stream (same approach as + /// [embedMetadata] and [_convertToAlac]). static Future _convertToFlac({ required String inputPath, required Map metadata, @@ -1390,35 +1393,32 @@ class FFmpegService { final cmdBuffer = StringBuffer(); cmdBuffer.write('-i "$inputPath" '); - cmdBuffer.write('-map 0:a '); - cmdBuffer.write('-c:a flac -compression_level 8 '); - cmdBuffer.write('-map_metadata -1 '); - // Embed Vorbis comments - for (final entry in metadata.entries) { - if (entry.value.trim().isEmpty) continue; - final sanitized = entry.value.replaceAll('"', '\\"'); - cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); + // Cover art as second input for attached picture + final hasCover = coverPath != null && + coverPath.trim().isNotEmpty && + await File(coverPath).exists(); + if (hasCover) { + cmdBuffer.write('-i "$coverPath" '); } - // Embed cover art via METADATA_BLOCK_PICTURE (same approach as Opus) - if (coverPath != null && coverPath.trim().isNotEmpty) { - try { - if (await File(coverPath).exists()) { - final pictureBlock = await _createMetadataBlockPicture(coverPath); - if (pictureBlock != null) { - final escapedBlock = pictureBlock.replaceAll('"', '\\"'); - cmdBuffer.write( - '-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ', - ); - _log.d( - 'Created METADATA_BLOCK_PICTURE for FLAC (${pictureBlock.length} chars)', - ); - } - } - } catch (e) { - _log.e('Error creating METADATA_BLOCK_PICTURE for FLAC: $e'); - } + cmdBuffer.write('-map 0:a '); + if (hasCover) { + cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic '); + cmdBuffer.write('-metadata:s:v title="Album cover" '); + cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); + } + cmdBuffer.write('-c:a flac -compression_level 8 '); + + // Copy source metadata as base (FFmpeg auto-remaps M4A/ID3 tags to + // Vorbis comment names), then override with our explicit values. + cmdBuffer.write('-map_metadata 0 '); + + // Apply normalized Vorbis comment overrides + final vorbisComments = _normalizeToVorbisComments(metadata); + for (final entry in vorbisComments.entries) { + final sanitized = entry.value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); } cmdBuffer.write('"$outputPath" -y'); @@ -1447,6 +1447,79 @@ class FFmpegService { return outputPath; } + /// Normalize metadata keys to standard Vorbis comment names and filter out + /// technical/non-tag fields (bit_depth, sample_rate, duration, etc.). + static Map _normalizeToVorbisComments( + Map metadata, + ) { + final vorbis = {}; + + for (final entry in metadata.entries) { + final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + final value = entry.value; + if (value.trim().isEmpty) continue; + + switch (key) { + case 'TITLE': + vorbis['TITLE'] = value; + break; + case 'ARTIST': + vorbis['ARTIST'] = value; + break; + case 'ALBUM': + vorbis['ALBUM'] = value; + break; + case 'ALBUMARTIST': + vorbis['ALBUMARTIST'] = value; + break; + case 'TRACKNUMBER': + case 'TRACKNBR': + case 'TRACK': + case 'TRCK': + if (value != '0') vorbis['TRACKNUMBER'] = value; + break; + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + if (value != '0') vorbis['DISCNUMBER'] = value; + break; + case 'DATE': + case 'YEAR': + vorbis['DATE'] = value; + break; + case 'GENRE': + vorbis['GENRE'] = value; + break; + case 'ISRC': + vorbis['ISRC'] = value; + break; + case 'LABEL': + case 'ORGANIZATION': + vorbis['ORGANIZATION'] = value; + break; + case 'COPYRIGHT': + vorbis['COPYRIGHT'] = value; + break; + case 'COMPOSER': + vorbis['COMPOSER'] = value; + break; + case 'COMMENT': + vorbis['COMMENT'] = value; + break; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + // Write both keys for compatibility with different FLAC readers + vorbis['LYRICS'] = value; + vorbis['UNSYNCEDLYRICS'] = value; + break; + // Technical fields (BIT_DEPTH, SAMPLE_RATE, DURATION, etc.) are + // intentionally dropped — they are not Vorbis comment tags. + } + } + + return vorbis; + } + /// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg. static Map _convertToM4aTags( Map metadata, From 6ebe0c51ce78b88c6031f02e027b02adb4a385f5 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 02:39:11 +0700 Subject: [PATCH 18/34] fix: filter batch convert target formats based on source formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exclude same-format and lossy-to-lossless targets from the batch convert sheet so users cannot pick pointless conversions like FLAC→FLAC. Also clean up redundant inline comments. --- lib/screens/downloaded_album_screen.dart | 42 ++++++++++++++++-- lib/screens/local_album_screen.dart | 54 ++++++++++++++++++++++-- lib/screens/queue_tab.dart | 48 +++++++++++++++++++-- lib/services/ffmpeg_service.dart | 19 ++------- 4 files changed, 135 insertions(+), 28 deletions(-) diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index f320aa40..ecbdbbe7 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -910,9 +910,44 @@ class _DownloadedAlbumScreenState extends ConsumerState { BuildContext context, List allTracks, ) { - String selectedFormat = 'MP3'; - String selectedBitrate = '320k'; - bool isLosslessTarget = false; + final tracksById = {for (final t in allTracks) t.id: t}; + final sourceFormats = {}; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + final nameToCheck = + (item.safFileName != null && item.safFileName!.isNotEmpty) + ? item.safFileName!.toLowerCase() + : item.filePath.toLowerCase(); + final ext = nameToCheck.endsWith('.flac') + ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' + : nameToCheck.endsWith('.mp3') + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' + : null; + if (ext != null) sourceFormats.add(ext); + } + + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { + return sourceFormats.any((src) { + if (src == target) return false; + final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; + final isLosslessSource = src == 'FLAC' || src == 'M4A'; + if (isLosslessTarget && !isLosslessSource) return false; + return true; + }); + }).toList(); + + if (formats.isEmpty) return; + + String selectedFormat = formats.first; + bool isLosslessTarget = + selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + String selectedBitrate = + isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k'); showModalBottomSheet( context: context, @@ -924,7 +959,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['ALAC', 'FLAC', 'MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 1b662324..42818a12 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1129,9 +1129,56 @@ class _LocalAlbumScreenState extends ConsumerState { BuildContext context, List allTracks, ) { - String selectedFormat = 'MP3'; - String selectedBitrate = '320k'; - bool isLosslessTarget = false; + final tracksById = {for (final t in allTracks) t.id: t}; + final sourceFormats = {}; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + String? ext; + if (item.format != null && item.format!.isNotEmpty) { + final fmt = item.format!.toLowerCase(); + if (fmt == 'flac') { + ext = 'FLAC'; + } else if (fmt == 'm4a') { + ext = 'M4A'; + } else if (fmt == 'mp3') { + ext = 'MP3'; + } else if (fmt == 'opus' || fmt == 'ogg') { + ext = 'Opus'; + } + } + if (ext == null) { + final lower = item.filePath.toLowerCase(); + if (lower.endsWith('.flac')) { + ext = 'FLAC'; + } else if (lower.endsWith('.m4a')) { + ext = 'M4A'; + } else if (lower.endsWith('.mp3')) { + ext = 'MP3'; + } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { + ext = 'Opus'; + } + } + if (ext != null) sourceFormats.add(ext); + } + + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { + return sourceFormats.any((src) { + if (src == target) return false; + final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; + final isLosslessSource = src == 'FLAC' || src == 'M4A'; + if (isLosslessTarget && !isLosslessSource) return false; + return true; + }); + }).toList(); + + if (formats.isEmpty) return; + + String selectedFormat = formats.first; + bool isLosslessTarget = + selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + String selectedBitrate = + isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k'); showModalBottomSheet( context: context, @@ -1143,7 +1190,6 @@ class _LocalAlbumScreenState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['ALAC', 'FLAC', 'MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 64f9c0e3..69b1aa44 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4755,9 +4755,50 @@ class _QueueTabState extends ConsumerState { BuildContext context, List allItems, ) async { - String selectedFormat = 'MP3'; - String selectedBitrate = '320k'; - bool isLosslessTarget = false; + final itemsById = {for (final item in allItems) item.id: item}; + final sourceFormats = {}; + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + String nameToCheck; + if (item.historyItem?.safFileName != null && + item.historyItem!.safFileName!.isNotEmpty) { + nameToCheck = item.historyItem!.safFileName!.toLowerCase(); + } else if (item.localItem?.format != null && + item.localItem!.format!.isNotEmpty) { + nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; + } else { + nameToCheck = item.filePath.toLowerCase(); + } + final ext = nameToCheck.endsWith('.flac') + ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' + : nameToCheck.endsWith('.mp3') + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' + : null; + if (ext != null) sourceFormats.add(ext); + } + + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { + return sourceFormats.any((src) { + if (src == target) return false; + final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; + final isLosslessSource = src == 'FLAC' || src == 'M4A'; + if (isLosslessTarget && !isLosslessSource) return false; + return true; + }); + }).toList(); + + if (formats.isEmpty) return; + + String selectedFormat = formats.first; + bool isLosslessTarget = + selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + String selectedBitrate = + isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k'); var didStartConversion = false; _hideSelectionOverlay(); @@ -4773,7 +4814,6 @@ class _QueueTabState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['ALAC', 'FLAC', 'MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index dfb88847..f5608c9f 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1377,12 +1377,7 @@ class FFmpegService { return outputPath; } - /// Convert any audio format to FLAC. - /// Source metadata is preserved via -map_metadata 0 (FFmpeg auto-remaps - /// tag names between container formats), then explicit Vorbis comment - /// overrides are applied from the [metadata] map. - /// Cover art is embedded via a second input stream (same approach as - /// [embedMetadata] and [_convertToAlac]). + /// Convert any audio format to FLAC with metadata and cover art preservation. static Future _convertToFlac({ required String inputPath, required Map metadata, @@ -1394,7 +1389,6 @@ class FFmpegService { final cmdBuffer = StringBuffer(); cmdBuffer.write('-i "$inputPath" '); - // Cover art as second input for attached picture final hasCover = coverPath != null && coverPath.trim().isNotEmpty && await File(coverPath).exists(); @@ -1409,12 +1403,8 @@ class FFmpegService { cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); } cmdBuffer.write('-c:a flac -compression_level 8 '); - - // Copy source metadata as base (FFmpeg auto-remaps M4A/ID3 tags to - // Vorbis comment names), then override with our explicit values. cmdBuffer.write('-map_metadata 0 '); - // Apply normalized Vorbis comment overrides final vorbisComments = _normalizeToVorbisComments(metadata); for (final entry in vorbisComments.entries) { final sanitized = entry.value.replaceAll('"', '\\"'); @@ -1447,8 +1437,8 @@ class FFmpegService { return outputPath; } - /// Normalize metadata keys to standard Vorbis comment names and filter out - /// technical/non-tag fields (bit_depth, sample_rate, duration, etc.). + /// Normalize metadata keys to standard Vorbis comment names, filtering out + /// technical fields (bit_depth, sample_rate, duration, etc.). static Map _normalizeToVorbisComments( Map metadata, ) { @@ -1508,12 +1498,9 @@ class FFmpegService { break; case 'LYRICS': case 'UNSYNCEDLYRICS': - // Write both keys for compatibility with different FLAC readers vorbis['LYRICS'] = value; vorbis['UNSYNCEDLYRICS'] = value; break; - // Technical fields (BIT_DEPTH, SAMPLE_RATE, DURATION, etc.) are - // intentionally dropped — they are not Vorbis comment tags. } } From 280b921755964f1525aa27bf2ab48e39a06b9971 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 02:43:13 +0700 Subject: [PATCH 19/34] fix: detect embedded lyrics in M4A/ALAC files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add extractLyricsFromM4A() that walks the MP4 box tree (moov/udta/meta/ilst/©lyr) to read lyrics. Wire it into ExtractLyrics so the Embed Lyrics button is hidden when lyrics already exist in the file. --- go_backend/metadata.go | 82 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 391b7e5d..19783cc2 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) { return extractLyricsFromSidecarLRC(filePath) } + if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") { + lyrics, err := extractLyricsFromM4A(filePath) + if err == nil && strings.TrimSpace(lyrics) != "" { + return lyrics, nil + } + return extractLyricsFromSidecarLRC(filePath) + } + if strings.HasSuffix(lower, ".mp3") { meta, err := ReadID3Tags(filePath) if err == nil && meta != nil { @@ -581,6 +589,80 @@ func ExtractLyrics(filePath string) (string, error) { return extractLyricsFromSidecarLRC(filePath) } +func extractLyricsFromM4A(filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return "", err + } + fileSize := fi.Size() + + moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) + if err != nil || !found { + return "", fmt.Errorf("moov not found") + } + + bodyStart := moov.offset + moov.headerSize + bodySize := moov.size - moov.headerSize + + udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize) + if err != nil || !found { + return "", fmt.Errorf("udta not found") + } + + bodyStart = udta.offset + udta.headerSize + bodySize = udta.size - udta.headerSize + + meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize) + if err != nil || !found { + return "", fmt.Errorf("meta not found") + } + + // meta atom has 4-byte version/flags after the header + bodyStart = meta.offset + meta.headerSize + 4 + bodySize = meta.size - meta.headerSize - 4 + + ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize) + if err != nil || !found { + return "", fmt.Errorf("ilst not found") + } + + bodyStart = ilst.offset + ilst.headerSize + bodySize = ilst.size - ilst.headerSize + + lyr, found, err := findAtomInRange(f, bodyStart, bodySize, "\xa9lyr", fileSize) + if err != nil || !found { + return "", fmt.Errorf("lyrics atom not found") + } + + dataStart := lyr.offset + lyr.headerSize + dataSize := lyr.size - lyr.headerSize + + dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize) + if err != nil || !found { + return "", fmt.Errorf("data atom not found in lyrics") + } + + // data atom: 8 bytes header + 4 bytes type indicator + 4 bytes locale = skip 8 + textStart := dataAtom.offset + dataAtom.headerSize + 8 + textLen := dataAtom.size - dataAtom.headerSize - 8 + if textLen <= 0 { + return "", fmt.Errorf("empty lyrics") + } + + buf := make([]byte, textLen) + if _, err := f.ReadAt(buf, textStart); err != nil { + return "", err + } + + return string(buf), nil +} + func extractLyricsFromSidecarLRC(filePath string) (string, error) { ext := filepath.Ext(filePath) base := strings.TrimSuffix(filePath, ext) From af6fa6ea53cb01cf885f0c7ee4893c8bf09799d2 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 02:49:48 +0700 Subject: [PATCH 20/34] fix: extract cover art from M4A/ALAC files for conversion Add extractCoverFromM4A() that reads the covr atom from the MP4 box tree (moov/udta/meta/ilst/covr/data). Wire it into ExtractCoverToFile so ALAC-to-FLAC conversion preserves cover art. --- go_backend/exports.go | 2 ++ go_backend/metadata.go | 73 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/go_backend/exports.go b/go_backend/exports.go index 1c5f84ed..e65b0197 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1694,6 +1694,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error { if strings.HasSuffix(lower, ".flac") { coverData, err = ExtractCoverArt(audioPath) + } else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") { + coverData, err = extractCoverFromM4A(audioPath) } else if strings.HasSuffix(lower, ".mp3") { coverData, _, err = extractMP3CoverArt(audioPath) } else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") { diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 19783cc2..d4d28e37 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -663,6 +663,79 @@ func extractLyricsFromM4A(filePath string) (string, error) { return string(buf), nil } +func extractCoverFromM4A(filePath string) ([]byte, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil, err + } + fileSize := fi.Size() + + moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("moov not found") + } + + bodyStart := moov.offset + moov.headerSize + bodySize := moov.size - moov.headerSize + + udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("udta not found") + } + + bodyStart = udta.offset + udta.headerSize + bodySize = udta.size - udta.headerSize + + meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("meta not found") + } + + bodyStart = meta.offset + meta.headerSize + 4 + bodySize = meta.size - meta.headerSize - 4 + + ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("ilst not found") + } + + bodyStart = ilst.offset + ilst.headerSize + bodySize = ilst.size - ilst.headerSize + + covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("cover atom not found") + } + + dataStart := covr.offset + covr.headerSize + dataSize := covr.size - covr.headerSize + + dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize) + if err != nil || !found { + return nil, fmt.Errorf("data atom not found in cover") + } + + // data atom: header + 4 bytes type indicator + 4 bytes locale + imgStart := dataAtom.offset + dataAtom.headerSize + 8 + imgLen := dataAtom.size - dataAtom.headerSize - 8 + if imgLen <= 0 { + return nil, fmt.Errorf("empty cover data") + } + + buf := make([]byte, imgLen) + if _, err := f.ReadAt(buf, imgStart); err != nil { + return nil, err + } + + return buf, nil +} + func extractLyricsFromSidecarLRC(filePath string) (string, error) { ext := filepath.Ext(filePath) base := strings.TrimSuffix(filePath, ext) From 09eb6cf20613dc0aaa1421ef54de86ced12f78a2 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 03:28:27 +0700 Subject: [PATCH 21/34] fix: use album-level artist for Various Artists albums instead of first track's artist - Extension: fix extractSchemaOrg to find album-level schema (with numTracks) instead of per-track schema - Extension: add secondaryText2 fallback in parseDescriptiveRows for VA album track artists - Extension: use headerPrimaryText as primary album artist source, overriding schema.org - App: album_screen now uses widget.artistName (album-level) instead of tracks.first.artistName - App: home_tab _parseTrack now populates albumArtist from track data or album-level artist - Bump Amazon extension to v2.0.1 --- lib/screens/album_screen.dart | 7 +++++-- lib/screens/home_tab.dart | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f498d834..139a5720 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -95,7 +95,7 @@ class _AlbumScreenState extends ConsumerState { .recordAlbumAccess( id: widget.albumId, name: widget.albumName, - artistName: widget.tracks?.firstOrNull?.artistName, + artistName: widget.artistName ?? widget.tracks?.firstOrNull?.albumArtist ?? widget.tracks?.firstOrNull?.artistName, imageUrl: widget.coverUrl, providerId: providerId, ); @@ -283,7 +283,10 @@ class _AlbumScreenState extends ConsumerState { ) { final expandedHeight = _calculateExpandedHeight(context); final tracks = _tracks ?? []; - final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; + final artistName = widget.artistName ?? + (tracks.isNotEmpty + ? (tracks.first.albumArtist ?? tracks.first.artistName) + : null); final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; return SliverAppBar( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 142c59de..1cffcf2d 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -3909,6 +3909,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? widget.albumName).toString(), + albumArtist: (data['album_artist'] ?? _artistName)?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, albumId: data['album_id']?.toString() ?? widget.albumId, From 529a920b24746d67168fcbd8665b60eebe155028 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 03:29:45 +0700 Subject: [PATCH 22/34] bump version to 3.8.5+111 --- lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 03766dff..88a98c6e 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart'; /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.8.0'; - static const String buildNumber = '106'; + static const String version = '3.8.5'; + static const String buildNumber = '111'; static const String fullVersion = '$version+$buildNumber'; /// Shows "Internal" in debug builds, actual version in release. diff --git a/pubspec.yaml b/pubspec.yaml index 1aa0fcf1..c54be740 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 3.8.0+106 +version: 3.8.5+111 environment: sdk: ^3.10.0 From 5b5f043624a7139ff085f2774aece2455c128538 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 03:36:42 +0700 Subject: [PATCH 23/34] docs: add extension store URL setup guide to README --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 21feb955..f6d99306 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,15 @@ Extensions allow the community to add new music sources and features without wai ### Installing Extensions 1. Go to **Store** tab in the app -2. Browse and install extensions with one tap -3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions** -4. Configure extension settings if needed -5. Set provider priority in **Settings > Extensions > Provider Priority** +2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**. You can use this community repository: + ``` + https://github.com/zarzet/SpotiFLAC-Extension + ``` + > **Why is this needed?** SpotiFLAC uses a decentralized extension system — extensions are hosted on GitHub repositories rather than a built-in server. This allows anyone to create and host their own extension repository. You only need to enter this URL once; the app will remember it. +3. Browse and install extensions with one tap +4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions** +5. Configure extension settings if needed +6. Set provider priority in **Settings > Extensions > Provider Priority** ### Developing Extensions Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation. @@ -55,6 +60,11 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window ## FAQ +**Q: Why does the Store tab ask me to enter a URL?** +A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension system. Extensions (like Amazon Music, YouTube Music, etc.) are hosted on GitHub repositories instead of a built-in server. On first launch, enter an extension repository URL in the Store tab, for example: +`https://github.com/zarzet/SpotiFLAC-Extension` +You only need to do this once — the app will save it and load extensions automatically on future launches. + **Q: Why is my download failing with "Song not found"?** A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store. From 15f977d98d7046e63970a0df4fcd5b3526872b87 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 03:48:40 +0700 Subject: [PATCH 24/34] fix: skip already-downloaded tracks in Download All for albums and playlists Album and playlist Download All buttons now check download history and local library before enqueuing, matching the existing behavior in artist discography and CSV import. Tracks already in library are skipped with a summary snackbar. --- lib/screens/album_screen.dart | 67 +++++++++++++++++++++++++------- lib/screens/playlist_screen.dart | 67 +++++++++++++++++++++++++------- 2 files changed, 104 insertions(+), 30 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 139a5720..9b8da6a4 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -574,37 +574,74 @@ class _AlbumScreenState extends ConsumerState { void _downloadAll(BuildContext context) { final tracks = _tracks; if (tracks == null || tracks.isEmpty) return; + + // Filter out tracks already in download history or local library + final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); + final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) + ? ref.read(localLibraryProvider) + : null; + final tracksToQueue = []; + int skippedCount = 0; + + for (final track in tracks) { + final isInHistory = historyState.isDownloaded(track.id) || + (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || + historyState.findByTrackAndArtist(track.name, track.artistName) != null; + final isInLocal = localLibState?.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ) ?? + false; + + if (isInHistory || isInLocal) { + skippedCount++; + } else { + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.discographySkippedDownloaded(0, skippedCount), + ), + ), + ); + return; + } + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '${tracks.length} tracks', + trackName: '${tracksToQueue.length} tracks', artistName: widget.albumName, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToQueue(tracks.length), - ), - ), - ); + .addMultipleToQueue(tracksToQueue, service, qualityOverride: quality); + _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); }, ); } else { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), - ), - ); + .addMultipleToQueue(tracksToQueue, settings.defaultService); + _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); } } + void _showQueuedSnackbar(BuildContext context, int added, int skipped) { + final message = skipped > 0 + ? context.l10n.discographySkippedDownloaded(added, skipped) + : context.l10n.snackbarAddedTracksToQueue(added); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + Widget _buildLoveAllButton() { final collectionsState = ref.watch(libraryCollectionsProvider); final tracks = _tracks; diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 8a035def..67d377b9 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -608,45 +608,82 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTracks(BuildContext context, List tracks) { if (tracks.isEmpty) return; + + // Filter out tracks already in download history or local library + final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); + final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) + ? ref.read(localLibraryProvider) + : null; + final tracksToQueue = []; + int skippedCount = 0; + + for (final track in tracks) { + final isInHistory = historyState.isDownloaded(track.id) || + (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || + historyState.findByTrackAndArtist(track.name, track.artistName) != null; + final isInLocal = localLibState?.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ) ?? + false; + + if (isInHistory || isInLocal) { + skippedCount++; + } else { + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.discographySkippedDownloaded(0, skippedCount), + ), + ), + ); + return; + } + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '${tracks.length} tracks', + trackName: '${tracksToQueue.length} tracks', artistName: _playlistName, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) .addMultipleToQueue( - tracks, + tracksToQueue, service, qualityOverride: quality, playlistName: _playlistName, ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToQueue(tracks.length), - ), - ), - ); + _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); }, ); } else { ref .read(downloadQueueProvider.notifier) .addMultipleToQueue( - tracks, + tracksToQueue, settings.defaultService, playlistName: _playlistName, ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), - ), - ); + _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); } } + + void _showQueuedSnackbar(BuildContext context, int added, int skipped) { + final message = skipped > 0 + ? context.l10n.discographySkippedDownloaded(added, skipped) + : context.l10n.snackbarAddedTracksToQueue(added); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } } /// Separate Consumer widget for each track - only rebuilds when this specific track's status changes From 9627ef66cf82d270535413f5b0f4445373879257 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 04:12:14 +0700 Subject: [PATCH 25/34] fix: verify resolved Tidal/Deezer tracks match the download request before downloading SongLink can return incorrect track IDs (e.g. a different track from the same album). Qobuz already had verification via qobuzTrackMatchesRequest. This adds equivalent verification for Tidal and Deezer using a shared trackMatchesRequest() helper in title_match_utils.go that checks artist, title, and duration. Mismatched SongLink/ISRC results are now rejected so the wrong audio is never embedded with Spotify metadata. --- go_backend/deezer_download.go | 53 +++++++++++++++++++++++++++----- go_backend/tidal.go | 26 ++++++++++++++++ go_backend/title_match_utils.go | 42 +++++++++++++++++++++++++ lib/screens/album_screen.dart | 8 ++--- lib/screens/playlist_screen.dart | 3 +- 5 files changed, 117 insertions(+), 15 deletions(-) diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index bef0e2ff..974ffcf6 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { } } if deezerID != "" { - return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID) + if err := verifyDeezerTrack(req, deezerID); err != nil { + GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err) + // Don't reject direct IDs from request payload — they're presumably correct. + } + return trackURL, nil } - // Try resolving Deezer ID from Spotify ID via SongLink + // Try SongLink spotifyID := strings.TrimSpace(req.SpotifyID) if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) { songlink := NewSongLinkClient() availability, err := songlink.CheckTrackAvailability(spotifyID, "") if err == nil && availability.Deezer && availability.DeezerURL != "" { - return availability.DeezerURL, nil + resolvedID := extractDeezerIDFromURL(availability.DeezerURL) + if resolvedID != "" { + if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { + GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr) + // Fall through to ISRC search instead of using wrong track. + } else { + return availability.DeezerURL, nil + } + } else { + return availability.DeezerURL, nil + } } } - // Try resolving from ISRC + // Try ISRC isrc := strings.TrimSpace(req.ISRC) if isrc != "" { ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) defer cancel() track, err := GetDeezerClient().SearchByISRC(ctx, isrc) if err == nil && track != nil { - deezerID = songLinkExtractDeezerTrackID(track) - if deezerID != "" { - return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + resolvedID := songLinkExtractDeezerTrackID(track) + if resolvedID != "" { + if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { + GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr) + return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr) + } + return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil } } } @@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { return "", fmt.Errorf("could not resolve Deezer track URL") } +func verifyDeezerTrack(req DownloadRequest, deezerID string) error { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID) + if err != nil { + return nil // Can't verify — don't block the download. + } + resolved := resolvedTrackInfo{ + Title: trackResp.Track.Name, + ArtistName: trackResp.Track.Artists, + Duration: trackResp.Track.DurationMS / 1000, + } + if !trackMatchesRequest(req, resolved, "Deezer") { + return fmt.Errorf("expected '%s - %s', got '%s - %s'", + req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title) + } + GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title) + return nil +} + type deezerMusicDLRequest struct { Platform string `json:"platform"` URL string `json:"url"` diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 582e4ff6..15f17b1c 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1911,6 +1911,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink") } + // Verify the resolved track matches the request. + actualTrack, fetchErr := downloader.getPublicTrack(strconv.FormatInt(trackID, 10)) + if fetchErr != nil { + GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr) + // Continue without verification — better than failing entirely. + } else { + providerArtist := actualTrack.Artist.Name + if providerArtist == "" && len(actualTrack.Artists) > 0 { + providerArtist = actualTrack.Artists[0].Name + } + resolved := resolvedTrackInfo{ + Title: actualTrack.Title, + ArtistName: providerArtist, + Duration: actualTrack.Duration, + } + if !trackMatchesRequest(req, resolved, logPrefix) { + // Invalidate the cached ID so future requests don't reuse it. + if req.ISRC != "" { + GetTrackIDCache().SetTidal(req.ISRC, 0) + } + return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'", + trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title) + } + GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title) + } + track := &TidalTrack{ ID: trackID, Title: strings.TrimSpace(req.TrackName), diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index 039ff434..22b0eebd 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -68,3 +68,45 @@ func normalizeSymbolOnlyTitle(title string) string { return b.String() } + +// ==================== Shared Track Verification ==================== + +// resolvedTrackInfo holds the metadata fetched from a provider for verification. +type resolvedTrackInfo struct { + Title string + ArtistName string + Duration int // seconds +} + +// trackMatchesRequest checks whether a resolved track from a provider matches +// the original download request. Returns true if the track is a plausible match. +func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool { + if req.ArtistName != "" && resolved.ArtistName != "" && + !artistsMatch(req.ArtistName, resolved.ArtistName) { + GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n", + logPrefix, req.ArtistName, resolved.ArtistName) + return false + } + + if req.TrackName != "" && resolved.Title != "" && + !titlesMatch(req.TrackName, resolved.Title) { + GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n", + logPrefix, req.TrackName, resolved.Title) + return false + } + + expectedDurationSec := req.DurationMS / 1000 + if expectedDurationSec > 0 && resolved.Duration > 0 { + diff := expectedDurationSec - resolved.Duration + if diff < 0 { + diff = -diff + } + if diff > 10 { + GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n", + logPrefix, expectedDurationSec, resolved.Duration) + return false + } + } + + return true +} diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 9b8da6a4..b54fb92a 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -81,7 +81,6 @@ class _AlbumScreenState extends ConsumerState { _scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - // Use extensionId if available, otherwise detect from albumId prefix final providerId = widget.extensionId ?? (() { @@ -134,9 +133,7 @@ class _AlbumScreenState extends ConsumerState { return (mediaSize.height * 0.55).clamp(360.0, 520.0); } - /// Upgrade cover URL to a reasonable resolution for full-screen display. - /// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate). - /// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800). + /// Upgrade cover URL to a higher resolution for full-screen display. String? _highResCoverUrl(String? url) { if (url == null) return null; // Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000) @@ -519,7 +516,6 @@ class _AlbumScreenState extends ConsumerState { } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - // Info is now displayed in the full-screen cover overlay return const SliverToBoxAdapter(child: SizedBox.shrink()); } @@ -575,7 +571,7 @@ class _AlbumScreenState extends ConsumerState { final tracks = _tracks; if (tracks == null || tracks.isEmpty) return; - // Filter out tracks already in download history or local library + // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 67d377b9..c2d9bff3 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -350,7 +350,6 @@ class _PlaylistScreenState extends ConsumerState { } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - // Info is now displayed in the full-screen cover overlay return const SliverToBoxAdapter(child: SizedBox.shrink()); } @@ -609,7 +608,7 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTracks(BuildContext context, List tracks) { if (tracks.isEmpty) return; - // Filter out tracks already in download history or local library + // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) From c7194064256e7380230b469f6a89a5f4b8f41040 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 04:24:18 +0700 Subject: [PATCH 26/34] docs: update readme --- README.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f6d99306..bfffd8ea 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,7 @@ Extensions allow the community to add new music sources and features without wai ### Installing Extensions 1. Go to **Store** tab in the app -2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**. You can use this community repository: - ``` - https://github.com/zarzet/SpotiFLAC-Extension - ``` - > **Why is this needed?** SpotiFLAC uses a decentralized extension system — extensions are hosted on GitHub repositories rather than a built-in server. This allows anyone to create and host their own extension repository. You only need to enter this URL once; the app will remember it. +2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL** 3. Browse and install extensions with one tap 4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions** 5. Configure extension settings if needed @@ -61,9 +57,7 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window ## FAQ **Q: Why does the Store tab ask me to enter a URL?** -A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension system. Extensions (like Amazon Music, YouTube Music, etc.) are hosted on GitHub repositories instead of a built-in server. On first launch, enter an extension repository URL in the Store tab, for example: -`https://github.com/zarzet/SpotiFLAC-Extension` -You only need to do this once — the app will save it and load extensions automatically on future launches. +A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions. **Q: Why is my download failing with "Song not found"?** A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store. From 95e755e54ea3bb96487ecc0e885ff8ca9c6f6a76 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 20:17:00 +0700 Subject: [PATCH 27/34] fix: delay iOS folder picker after sheet dismiss and update Afkar hosts --- CHANGELOG.md | 4 ++-- go_backend/qobuz.go | 2 +- go_backend/spotfetch_api.go | 2 +- .../settings/download_settings_page.dart | 21 ++++++++++++++++++- lib/screens/setup_screen.dart | 21 ++++++++++++++++++- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8b7254..9916536f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t - Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service - New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)` - SpotFetch metadata fallback integration for Spotify-blocked regions - - New backend client for `spotify.afkarxyz.fun/api` + - New backend client for `sp.afkarxyz.qzz.io/api` - Automatic fallback in Spotify metadata fetch path when primary source fails - Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC - Includes heuristic detection of lyrics stored in Comment fields @@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t - Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated` - Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers) - Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths) -- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support +- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support - Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback - Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow) - Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index ec0489ff..e354f479 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -54,7 +54,7 @@ const ( qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" - qobuzAfkarAPIURL = "https://qbz.afkarxyz.fun/api/track/" + qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/" qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id=" qobuzDebugKeyXORMask = byte(0x5A) ) diff --git a/go_backend/spotfetch_api.go b/go_backend/spotfetch_api.go index d6bfab19..e12804a4 100644 --- a/go_backend/spotfetch_api.go +++ b/go_backend/spotfetch_api.go @@ -10,7 +10,7 @@ import ( "time" ) -const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api" +const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api" // GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API. // This is used as a fallback when direct Spotify API access is blocked/limited. diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index b4a6d1a7..fb583df7 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -1310,8 +1310,27 @@ class _DownloadSettingsPageState extends ConsumerState { subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), onTap: () async { Navigator.pop(ctx); + if (Platform.isIOS) { + await Future.delayed(const Duration(milliseconds: 250)); + } + // Note: iOS requires folder to have at least one file to be selectable - final result = await FilePicker.platform.getDirectoryPath(); + String? result; + try { + result = await FilePicker.platform.getDirectoryPath(); + } catch (e) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text('Failed to open folder picker: $e'), + backgroundColor: Theme.of(ctx).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); + } + return; + } + if (result != null) { // iOS: Validate the selected path is writable (not iCloud or container root) if (Platform.isIOS) { diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 7c74d92e..6d12825d 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -321,7 +321,26 @@ class _SetupScreenState extends ConsumerState { title: Text(context.l10n.setupChooseFromFiles), onTap: () async { Navigator.pop(ctx); - final result = await FilePicker.platform.getDirectoryPath(); + if (Platform.isIOS) { + await Future.delayed(const Duration(milliseconds: 250)); + } + + String? result; + try { + result = await FilePicker.platform.getDirectoryPath(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to open folder picker: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); + } + return; + } + if (result != null) { // iOS: Validate the selected path is writable if (Platform.isIOS) { From e63e3662289ed61d9518031ad5538757ae4910bb Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 20:28:25 +0700 Subject: [PATCH 28/34] feat: add mc nuggets jimmy, CJBGR and michahRicie as supporters Add new supporters to the donate page. michahRicie is highlighted as a gold supporter. --- lib/screens/settings/donate_page.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index f822f5d3..e1f76a6d 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -164,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - const donorNames = ['a fan']; + const donorNames = ['a fan', 'mc nuggets jimmy', 'CJBGR', 'michahRicie']; // Match SettingsGroup color logic final cardColor = isDark @@ -479,8 +479,8 @@ int _cr(String v) { return r; } -// Highlighted supporters (hashes of names): none for now. -const _cv = {}; +// Highlighted supporters (hashes of names). +const _cv = {1365043105}; class _SupporterChip extends StatelessWidget { final String name; From f170ead7b9aeb99c5c2678721eb401db52010134 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 20:28:31 +0700 Subject: [PATCH 29/34] docs: add contributors section to README Add auto-generated contributor avatars via contrib.rocks with a link to the GitHub contributors page. Include acknowledgement for translators and bug reporters. --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index bfffd8ea..4ad91b55 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,18 @@ _If this software is useful and brings you value, consider supporting the projec [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) +## Contributors + +Thanks to all the amazing people who have contributed to SpotiFLAC Mobile! + + + + + +We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile. + +Interested in contributing? Check out our [Contributing Guide](CONTRIBUTING.md) to get started! + ## API Credits [hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify) From 929c5f324969ef45f9077b8c42e9bfb8a77be041 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 20:28:37 +0700 Subject: [PATCH 30/34] fix: remove double horizontal padding in store tab extension list The extension list was wrapped in an extra Padding(horizontal: 16) on top of SettingsGroup's default 16px margin, resulting in 32px total inset. Remove the outer wrapper to match settings tab width. --- lib/screens/store_tab.dart | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 72c91444..63256832 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -269,22 +269,19 @@ class _StoreTabState extends ConsumerState { ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: SettingsGroup( - children: filteredExtensions.asMap().entries.map((entry) { - final index = entry.key; - final ext = entry.value; - return _ExtensionItem( - extension: ext, - showDivider: index < filteredExtensions.length - 1, - isDownloading: downloadingId == ext.id, - onInstall: () => _installExtension(ext), - onUpdate: () => _updateExtension(ext), - onTap: () => _showExtensionDetails(ext), - ); - }).toList(), - ), + child: SettingsGroup( + children: filteredExtensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < filteredExtensions.length - 1, + isDownloading: downloadingId == ext.id, + onInstall: () => _installExtension(ext), + onUpdate: () => _updateExtension(ext), + onTap: () => _showExtensionDetails(ext), + ); + }).toList(), ), ), From 6710f90e1e7736b4530f6cc510269d7fcea14cfa Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 20:28:45 +0700 Subject: [PATCH 31/34] feat: add auto-scan option for local library Add a new 'Auto Scan' setting under Local Library with four modes: off, every app open (10min cooldown), daily, and weekly. The app uses WidgetsBindingObserver to trigger incremental scans on launch and when resuming from background, respecting the configured cooldown based on the last scan timestamp. --- lib/l10n/app_localizations.dart | 36 +++++ lib/l10n/app_localizations_de.dart | 19 +++ lib/l10n/app_localizations_en.dart | 19 +++ lib/l10n/app_localizations_es.dart | 19 +++ lib/l10n/app_localizations_fr.dart | 19 +++ lib/l10n/app_localizations_hi.dart | 19 +++ lib/l10n/app_localizations_id.dart | 19 +++ lib/l10n/app_localizations_ja.dart | 19 +++ lib/l10n/app_localizations_ko.dart | 19 +++ lib/l10n/app_localizations_nl.dart | 19 +++ lib/l10n/app_localizations_pt.dart | 19 +++ lib/l10n/app_localizations_ru.dart | 19 +++ lib/l10n/app_localizations_tr.dart | 19 +++ lib/l10n/app_localizations_zh.dart | 19 +++ lib/l10n/arb/app_en.arb | 24 ++++ lib/main.dart | 75 +++++++++- lib/models/settings.dart | 6 + lib/models/settings.g.dart | 2 + lib/providers/settings_provider.dart | 5 + .../settings/library_settings_page.dart | 134 +++++++++++++++++- 20 files changed, 526 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 15451886..0ea69f2b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3106,6 +3106,42 @@ abstract class AppLocalizations { /// **'Show when searching for existing tracks'** String get libraryShowDuplicateIndicatorSubtitle; + /// Setting for automatic library scanning + /// + /// In en, this message translates to: + /// **'Auto Scan'** + String get libraryAutoScan; + + /// Subtitle for auto scan setting + /// + /// In en, this message translates to: + /// **'Automatically scan your library for new files'** + String get libraryAutoScanSubtitle; + + /// Auto scan disabled + /// + /// In en, this message translates to: + /// **'Off'** + String get libraryAutoScanOff; + + /// Auto scan when app opens + /// + /// In en, this message translates to: + /// **'Every app open'** + String get libraryAutoScanOnOpen; + + /// Auto scan once per day + /// + /// In en, this message translates to: + /// **'Daily'** + String get libraryAutoScanDaily; + + /// Auto scan once per week + /// + /// In en, this message translates to: + /// **'Weekly'** + String get libraryAutoScanWeekly; + /// Section header for library actions /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f0137de1..7ad45e21 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1719,6 +1719,25 @@ class AppLocalizationsDe extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Bei der Suche nach vorhandenen Titeln anzeigen'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Aktionen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 041c5bd1..13208d5b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1695,6 +1695,25 @@ class AppLocalizationsEn extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 99849885..113c08f1 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1695,6 +1695,25 @@ class AppLocalizationsEs extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index cec6eae9..6521f755 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1697,6 +1697,25 @@ class AppLocalizationsFr extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 9a9ad518..04227142 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1695,6 +1695,25 @@ class AppLocalizationsHi extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 902d49cd..b23768e3 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1702,6 +1702,25 @@ class AppLocalizationsId extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 31270670..caf7d16e 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1682,6 +1682,25 @@ class AppLocalizationsJa extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'アクション'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 76f1b737..1cac05e7 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1675,6 +1675,25 @@ class AppLocalizationsKo extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index d509e1e7..ac2c8b57 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1695,6 +1695,25 @@ class AppLocalizationsNl extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ef87eca7..3c5c8896 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1695,6 +1695,25 @@ class AppLocalizationsPt extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 8bbb8a74..31a1a1dc 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1731,6 +1731,25 @@ class AppLocalizationsRu extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Показать при поиске существующих треков'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Действия'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index bbf6ef14..518bcf77 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1707,6 +1707,25 @@ class AppLocalizationsTr extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 465f196c..d36cf154 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1695,6 +1695,25 @@ class AppLocalizationsZh extends AppLocalizations { String get libraryShowDuplicateIndicatorSubtitle => 'Show when searching for existing tracks'; + @override + String get libraryAutoScan => 'Auto Scan'; + + @override + String get libraryAutoScanSubtitle => + 'Automatically scan your library for new files'; + + @override + String get libraryAutoScanOff => 'Off'; + + @override + String get libraryAutoScanOnOpen => 'Every app open'; + + @override + String get libraryAutoScanDaily => 'Daily'; + + @override + String get libraryAutoScanWeekly => 'Weekly'; + @override String get libraryActions => 'Actions'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7f4e1acb..7055840d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2242,6 +2242,30 @@ "@libraryShowDuplicateIndicatorSubtitle": { "description": "Subtitle for duplicate indicator toggle" }, + "libraryAutoScan": "Auto Scan", + "@libraryAutoScan": { + "description": "Setting for automatic library scanning" + }, + "libraryAutoScanSubtitle": "Automatically scan your library for new files", + "@libraryAutoScanSubtitle": { + "description": "Subtitle for auto scan setting" + }, + "libraryAutoScanOff": "Off", + "@libraryAutoScanOff": { + "description": "Auto scan disabled" + }, + "libraryAutoScanOnOpen": "Every app open", + "@libraryAutoScanOnOpen": { + "description": "Auto scan when app opens" + }, + "libraryAutoScanDaily": "Daily", + "@libraryAutoScanDaily": { + "description": "Auto scan once per day" + }, + "libraryAutoScanWeekly": "Weekly", + "@libraryAutoScanWeekly": { + "description": "Auto scan once per week" + }, "libraryActions": "Actions", "@libraryActions": { "description": "Section header for library actions" diff --git a/lib/main.dart b/lib/main.dart index 63d74d3a..7ead4a81 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/app.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -90,16 +91,21 @@ class _EagerInitialization extends ConsumerStatefulWidget { _EagerInitializationState(); } -class _EagerInitializationState extends ConsumerState<_EagerInitialization> { +class _EagerInitializationState extends ConsumerState<_EagerInitialization> + with WidgetsBindingObserver { ProviderSubscription? _localLibraryEnabledSub; Timer? _downloadHistoryWarmupTimer; Timer? _libraryCollectionsWarmupTimer; Timer? _localLibraryWarmupTimer; bool _localLibraryWarmupScheduled = false; + bool _autoScanTriggeredOnLaunch = false; + + static const _lastScannedAtKey = 'local_library_last_scanned_at'; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _initializeAppServices(); @@ -110,6 +116,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _localLibraryEnabledSub?.close(); _downloadHistoryWarmupTimer?.cancel(); _libraryCollectionsWarmupTimer?.cancel(); @@ -117,6 +124,13 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _maybeAutoScanLocalLibrary(); + } + } + void _initializeDeferredProviders() { _downloadHistoryWarmupTimer = _scheduleProviderWarmup( const Duration(milliseconds: 400), @@ -155,7 +169,64 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { _localLibraryWarmupScheduled = true; _localLibraryWarmupTimer = _scheduleProviderWarmup( const Duration(milliseconds: 1600), - () => ref.read(localLibraryProvider), + () { + ref.read(localLibraryProvider); + // Trigger auto-scan after initial warmup on first app launch. + if (!_autoScanTriggeredOnLaunch) { + _autoScanTriggeredOnLaunch = true; + // Give the provider a moment to load existing data before scanning. + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) _maybeAutoScanLocalLibrary(); + }); + } + }, + ); + } + + /// Checks whether an automatic incremental scan should be triggered based on + /// the user's auto-scan preference and the time since the last scan. + Future _maybeAutoScanLocalLibrary() async { + if (!mounted) return; + + final settings = ref.read(settingsProvider); + if (!settings.localLibraryEnabled) return; + if (settings.localLibraryPath.isEmpty) return; + if (settings.localLibraryAutoScan == 'off') return; + + // Don't start a scan if one is already running. + final libraryState = ref.read(localLibraryProvider); + if (libraryState.isScanning) return; + + // Determine cooldown based on auto-scan mode. + final now = DateTime.now(); + final prefs = await SharedPreferences.getInstance(); + final lastScannedMs = prefs.getInt(_lastScannedAtKey); + + if (lastScannedMs != null) { + final lastScanned = DateTime.fromMillisecondsSinceEpoch(lastScannedMs); + final elapsed = now.difference(lastScanned); + + switch (settings.localLibraryAutoScan) { + case 'on_open': + // Cooldown of 10 minutes to prevent rapid re-scans. + if (elapsed.inMinutes < 10) return; + break; + case 'daily': + if (elapsed.inHours < 24) return; + break; + case 'weekly': + if (elapsed.inDays < 7) return; + break; + default: + return; + } + } + + // All checks passed -- start an incremental scan. + final iosBookmark = settings.localLibraryBookmark; + ref.read(localLibraryProvider.notifier).startScan( + settings.localLibraryPath, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, ); } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 9bf2ee10..87c31e1b 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -59,6 +59,8 @@ class AppSettings { localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks + final String + localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly' final bool hasCompletedTutorial; // Track if user has completed the app tutorial @@ -123,6 +125,7 @@ class AppSettings { this.localLibraryPath = '', this.localLibraryBookmark = '', this.localLibraryShowDuplicates = true, + this.localLibraryAutoScan = 'off', this.hasCompletedTutorial = false, this.lyricsProviders = const [ 'lrclib', @@ -186,6 +189,7 @@ class AppSettings { String? localLibraryPath, String? localLibraryBookmark, bool? localLibraryShowDuplicates, + String? localLibraryAutoScan, bool? hasCompletedTutorial, List? lyricsProviders, bool? lyricsIncludeTranslationNetease, @@ -251,6 +255,8 @@ class AppSettings { localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark, localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, + localLibraryAutoScan: + localLibraryAutoScan ?? this.localLibraryAutoScan, hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, lyricsProviders: lyricsProviders ?? this.lyricsProviders, lyricsIncludeTranslationNetease: diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 99a5114d..2b6a35cf 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -57,6 +57,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '', localLibraryShowDuplicates: json['localLibraryShowDuplicates'] as bool? ?? true, + localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off', hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false, lyricsProviders: (json['lyricsProviders'] as List?) @@ -129,6 +130,7 @@ Map _$AppSettingsToJson( 'localLibraryPath': instance.localLibraryPath, 'localLibraryBookmark': instance.localLibraryBookmark, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, + 'localLibraryAutoScan': instance.localLibraryAutoScan, 'hasCompletedTutorial': instance.hasCompletedTutorial, 'lyricsProviders': instance.lyricsProviders, 'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index e98a70e0..33706c57 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -518,6 +518,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setLocalLibraryAutoScan(String mode) { + state = state.copyWith(localLibraryAutoScan: mode); + _saveSettings(); + } + void setTutorialComplete() { state = state.copyWith(hasCompletedTutorial: true); _saveSettings(); diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index e71c1046..608eb4bf 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -241,6 +241,99 @@ class _LibrarySettingsPageState extends ConsumerState { } } + String _getAutoScanLabel(BuildContext context, String mode) { + switch (mode) { + case 'on_open': + return context.l10n.libraryAutoScanOnOpen; + case 'daily': + return context.l10n.libraryAutoScanDaily; + case 'weekly': + return context.l10n.libraryAutoScanWeekly; + default: + return context.l10n.libraryAutoScanOff; + } + } + + void _showAutoScanPicker(BuildContext context, String current) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.libraryAutoScan, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.libraryAutoScanSubtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + _AutoScanOption( + icon: Icons.block, + title: context.l10n.libraryAutoScanOff, + selected: current == 'off', + colorScheme: colorScheme, + onTap: () { + ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('off'); + Navigator.pop(context); + }, + ), + _AutoScanOption( + icon: Icons.open_in_new, + title: context.l10n.libraryAutoScanOnOpen, + selected: current == 'on_open', + colorScheme: colorScheme, + onTap: () { + ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('on_open'); + Navigator.pop(context); + }, + ), + _AutoScanOption( + icon: Icons.today, + title: context.l10n.libraryAutoScanDaily, + selected: current == 'daily', + colorScheme: colorScheme, + onTap: () { + ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('daily'); + Navigator.pop(context); + }, + ), + _AutoScanOption( + icon: Icons.date_range, + title: context.l10n.libraryAutoScanWeekly, + selected: current == 'weekly', + colorScheme: colorScheme, + onTap: () { + ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('weekly'); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); @@ -344,7 +437,18 @@ class _LibrarySettingsPageState extends ConsumerState { onChanged: (value) => ref .read(settingsProvider.notifier) .setLocalLibraryShowDuplicates(value), - showDivider: false, + ), + Opacity( + opacity: settings.localLibraryEnabled ? 1.0 : 0.5, + child: SettingsItem( + icon: Icons.autorenew_rounded, + title: context.l10n.libraryAutoScan, + subtitle: _getAutoScanLabel(context, settings.localLibraryAutoScan), + onTap: settings.localLibraryEnabled + ? () => _showAutoScanPicker(context, settings.localLibraryAutoScan) + : null, + showDivider: false, + ), ), ], ), @@ -825,3 +929,31 @@ class _ScanProgressTile extends StatelessWidget { ); } } + +class _AutoScanOption extends StatelessWidget { + final IconData icon; + final String title; + final bool selected; + final ColorScheme colorScheme; + final VoidCallback onTap; + + const _AutoScanOption({ + required this.icon, + required this.title, + required this.selected, + required this.colorScheme, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(title), + trailing: selected + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: onTap, + ); + } +} From a8a39732250692994e11cfb446b14b4ab5a8b39d Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 20:28:53 +0700 Subject: [PATCH 32/34] fix: prevent re-download of tracks converted to a different format When a file is converted externally (e.g. FLAC to OPUS), the orphan cleanup would delete the history entry because the original path no longer exists. Now it checks for sibling files with other audio extensions and updates the stored path instead of deleting. Also add extension-stripped keys to path_match_keys so that paths differing only by audio extension still match during local library scan exclusion and queue deduplication. --- lib/providers/download_queue_provider.dart | 47 +++++++++++++++++++++- lib/screens/settings/donate_page.dart | 4 +- lib/utils/path_match_keys.dart | 39 ++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a01b8e88..6f983bf4 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -770,6 +770,37 @@ class DownloadHistoryNotifier extends Notifier { /// Remove history entries where the file no longer exists on disk /// Returns the number of orphaned entries removed + /// Audio file extensions that the app commonly produces or converts between. + static const _audioExtensions = [ + '.flac', + '.m4a', + '.mp3', + '.opus', + '.ogg', + '.wav', + '.aac', + ]; + + /// When the original file is missing, check whether a sibling with a + /// different audio extension exists (e.g. the user converted .flac → .opus). + /// Returns the path of the first match found, or `null` if none exist. + Future _findConvertedSibling(String originalPath) async { + // Strip the current extension to get the base path. + final dotIndex = originalPath.lastIndexOf('.'); + if (dotIndex < 0) return null; + final basePath = originalPath.substring(0, dotIndex); + final originalExt = originalPath.substring(dotIndex).toLowerCase(); + + for (final ext in _audioExtensions) { + if (ext == originalExt) continue; + final candidatePath = '$basePath$ext'; + try { + if (await fileExists(candidatePath)) return candidatePath; + } catch (_) {} + } + return null; + } + Future cleanupOrphanedDownloads() async { _historyLog.i('Starting orphaned downloads cleanup...'); @@ -791,7 +822,21 @@ class DownloadHistoryNotifier extends Notifier { if (filePath == null || filePath.isEmpty) return null; pathById[id] = filePath; try { - return MapEntry(id, await fileExists(filePath)); + if (await fileExists(filePath)) return MapEntry(id, true); + + // Original file missing -- check for a converted sibling. + final sibling = await _findConvertedSibling(filePath); + if (sibling != null) { + _historyLog.i( + 'Found converted sibling for $id: $filePath → $sibling', + ); + // Update the stored path so future checks succeed immediately. + await _db.updateFilePath(id, sibling); + pathById[id] = sibling; + return MapEntry(id, true); + } + + return MapEntry(id, false); } catch (e) { _historyLog.w('Error checking file existence for $id: $e'); return MapEntry(id, false); diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index e1f76a6d..b810f003 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -164,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - const donorNames = ['a fan', 'mc nuggets jimmy', 'CJBGR', 'michahRicie']; + const donorNames = ['micahRichie', 'a fan', 'mc nuggets jimmy', 'CJBGR']; // Match SettingsGroup color logic final cardColor = isDark @@ -480,7 +480,7 @@ int _cr(String v) { } // Highlighted supporters (hashes of names). -const _cv = {1365043105}; +const _cv = {1211573191}; class _SupporterChip extends StatelessWidget { final String name; diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart index ace140dd..0df1c023 100644 --- a/lib/utils/path_match_keys.dart +++ b/lib/utils/path_match_keys.dart @@ -8,6 +8,33 @@ const _androidStoragePathAliases = [ '/mnt/sdcard', ]; +/// Audio file extensions that the app commonly produces or converts between. +/// Used to generate extension-stripped match keys so that a file converted from +/// one format to another (e.g. .flac → .opus) is still recognised as the same +/// track. +const _audioExtensions = [ + '.flac', + '.m4a', + '.mp3', + '.opus', + '.ogg', + '.wav', + '.aac', +]; + +/// Strips a trailing audio extension from [path] if present. +/// Returns the path without extension, or `null` if no known audio extension +/// was found. +String? _stripAudioExtension(String path) { + final lower = path.toLowerCase(); + for (final ext in _audioExtensions) { + if (lower.endsWith(ext)) { + return path.substring(0, path.length - ext.length); + } + } + return null; +} + Set buildPathMatchKeys(String? filePath) { final raw = filePath?.trim() ?? ''; if (raw.isEmpty) return const {}; @@ -79,6 +106,18 @@ Set buildPathMatchKeys(String? filePath) { } addNormalized(cleaned); + + // Add extension-stripped variants so that a file converted from one audio + // format to another (e.g. Song.flac → Song.opus) still matches. + final extensionStrippedKeys = {}; + for (final key in keys) { + final stripped = _stripAudioExtension(key); + if (stripped != null && stripped.isNotEmpty) { + extensionStrippedKeys.add(stripped); + } + } + keys.addAll(extensionStrippedKeys); + return keys; } From 8529985a0ebb7fcfe3927ca119cd30304e8c967e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 13:54:09 +0000 Subject: [PATCH 33/34] chore: update AltStore source to v3.8.6 --- apps.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps.json b/apps.json index 81613e65..fd8aa9e2 100644 --- a/apps.json +++ b/apps.json @@ -7,12 +7,12 @@ "name": "SpotiFLAC", "bundleIdentifier": "com.zarzet.spotiflac", "developerName": "zarzet", - "version": "3.8.5", - "versionDate": "2026-03-15", - "downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.5/SpotiFLAC-v3.8.5-ios-unsigned.ipa", + "version": "3.8.6", + "versionDate": "2026-03-16", + "downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-ios-unsigned.ipa", "localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.", "iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png", - "size": 33673615 + "size": 33676960 } ] } From c66d13c9fde31fecc5413dd585da34a9de6cd203 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 21:02:03 +0700 Subject: [PATCH 34/34] bump version to 3.8.6+112 --- lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 88a98c6e..854285d6 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart'; /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.8.5'; - static const String buildNumber = '111'; + static const String version = '3.8.6'; + static const String buildNumber = '112'; static const String fullVersion = '$version+$buildNumber'; /// Shows "Internal" in debug builds, actual version in release. diff --git a/pubspec.yaml b/pubspec.yaml index c54be740..540615c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 3.8.5+111 +version: 3.8.6+112 environment: sdk: ^3.10.0