Merge branch 'dev'

This commit is contained in:
zarzet committed 2026-03-11 16:42:09 +07:00
commit 29165da5ac
136 files changed
+12862 -7088

No files matched your search

-1
View File
@@ -17,7 +17,6 @@ final _routerProvider = Provider<GoRouter>((ref) {
settingsProvider.select((s) => s.hasCompletedTutorial),
);
// Determine initial location based on app state
String initialLocation;
if (isFirstLaunch) {
initialLocation = '/setup';
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.7.1';
static const String buildNumber = '104';
static const String version = '3.7.2';
static const String buildNumber = '105';
static const String fullVersion = '$version+$buildNumber';
+105 -3
View File
@@ -763,7 +763,7 @@ abstract class AppLocalizations {
/// App description in header card
///
/// In en, this message translates to:
/// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'**
/// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'**
String get aboutAppDescription;
/// Section header for artist albums
@@ -1306,6 +1306,24 @@ abstract class AppLocalizations {
/// **'No tracks found'**
String get errorNoTracksFound;
/// Error title - URL not handled by any extension or service
///
/// In en, this message translates to:
/// **'Link not recognized'**
String get errorUrlNotRecognized;
/// Error message - URL not recognized explanation
///
/// In en, this message translates to:
/// **'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'**
String get errorUrlNotRecognizedMessage;
/// Error message - generic URL fetch failure
///
/// In en, this message translates to:
/// **'Failed to load content from this link. Please try again.'**
String get errorUrlFetchFailed;
/// Error - extension source not available
///
/// In en, this message translates to:
@@ -1438,6 +1456,18 @@ abstract class AppLocalizations {
/// **'No organization'**
String get folderOrganizationNone;
/// Folder option - playlist folders
///
/// In en, this message translates to:
/// **'By Playlist'**
String get folderOrganizationByPlaylist;
/// Subtitle for playlist folder option
///
/// In en, this message translates to:
/// **'Separate folder for each playlist'**
String get folderOrganizationByPlaylistSubtitle;
/// Folder option - artist folders
///
/// In en, this message translates to:
@@ -1576,7 +1606,7 @@ abstract class AppLocalizations {
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
String get providerPriorityInfo;
/// Label for built-in providers (Tidal/Qobuz/Amazon)
/// Label for built-in providers (Tidal/Qobuz)
///
/// In en, this message translates to:
/// **'Built-in'**
@@ -3271,7 +3301,7 @@ abstract class AppLocalizations {
/// Tutorial welcome tip 2
///
/// In en, this message translates to:
/// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'**
/// **'Get FLAC quality audio from Tidal, Qobuz, or Deezer'**
String get tutorialWelcomeTip2;
/// Tutorial welcome tip 3
@@ -3794,6 +3824,78 @@ abstract class AppLocalizations {
/// **'Conversion failed'**
String get trackConvertFailed;
/// Title for CUE split bottom sheet
///
/// In en, this message translates to:
/// **'Split CUE Sheet'**
String get cueSplitTitle;
/// Subtitle for CUE split menu item
///
/// In en, this message translates to:
/// **'Split CUE+FLAC into individual tracks'**
String get cueSplitSubtitle;
/// Album name in CUE split sheet
///
/// In en, this message translates to:
/// **'Album: {album}'**
String cueSplitAlbum(String album);
/// Artist name in CUE split sheet
///
/// In en, this message translates to:
/// **'Artist: {artist}'**
String cueSplitArtist(String artist);
/// Number of tracks in CUE sheet
///
/// In en, this message translates to:
/// **'{count} tracks'**
String cueSplitTrackCount(int count);
/// CUE split confirmation dialog title
///
/// In en, this message translates to:
/// **'Split CUE Album'**
String get cueSplitConfirmTitle;
/// CUE split confirmation dialog message
///
/// In en, this message translates to:
/// **'Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.'**
String cueSplitConfirmMessage(String album, int count);
/// Snackbar while splitting CUE
///
/// In en, this message translates to:
/// **'Splitting CUE sheet... ({current}/{total})'**
String cueSplitSplitting(int current, int total);
/// Snackbar after successful CUE split
///
/// In en, this message translates to:
/// **'Split into {count} tracks successfully'**
String cueSplitSuccess(int count);
/// Snackbar when CUE split fails
///
/// In en, this message translates to:
/// **'CUE split failed'**
String get cueSplitFailed;
/// Error when CUE audio file is missing
///
/// In en, this message translates to:
/// **'Audio file not found for this CUE sheet'**
String get cueSplitNoAudioFile;
/// Button text to start CUE splitting
///
/// In en, this message translates to:
/// **'Split into Tracks'**
String get cueSplitButton;
/// Generic action button - create
///
/// In en, this message translates to:
File diff suppressed because it is too large. Load diff
+68 -2
View File
@@ -356,7 +356,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@@ -688,6 +688,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1809,7 +1827,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2126,6 +2144,54 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
+70 -4
View File
@@ -356,7 +356,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@@ -688,6 +688,17 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1809,7 +1827,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2126,6 +2144,54 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2705,7 +2771,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get aboutAppDescription =>
'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.';
'Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.';
@override
String get artistAlbums => 'Álbumes';
@@ -4150,7 +4216,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer';
@override
String get tutorialWelcomeTip3 =>
+66
View File
@@ -690,6 +690,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -763,6 +774,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -2128,6 +2146,54 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
+66
View File
@@ -688,6 +688,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -2126,6 +2144,54 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
+141 -76
View File
@@ -15,7 +15,7 @@ class AppLocalizationsId extends AppLocalizations {
String get navHome => 'Beranda';
@override
String get navLibrary => 'Library';
String get navLibrary => 'Pustaka';
@override
String get navSettings => 'Pengaturan';
@@ -45,7 +45,7 @@ class AppLocalizationsId extends AppLocalizations {
String get historyFilterSingles => 'Single';
@override
String get historySearchHint => 'Search history...';
String get historySearchHint => 'Cari riwayat...';
@override
String get settingsTitle => 'Pengaturan';
@@ -104,7 +104,7 @@ class AppLocalizationsId extends AppLocalizations {
String get appearanceHistoryViewList => 'Daftar';
@override
String get appearanceHistoryViewGrid => 'Grid';
String get appearanceHistoryViewGrid => 'Kisi';
@override
String get optionsTitle => 'Opsi';
@@ -126,7 +126,7 @@ class AppLocalizationsId extends AppLocalizations {
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
@override
String get optionsAutoFallback => 'Auto Fallback';
String get optionsAutoFallback => 'Cadangan Otomatis';
@override
String get optionsAutoFallbackSubtitle =>
@@ -217,7 +217,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String optionsSpotifyCredentialsConfigured(String clientId) {
return 'Client ID: $clientId...';
return 'ID Klien: $clientId...';
}
@override
@@ -230,7 +230,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
@override
String get extensionsTitle => 'Ekstensi';
@@ -283,7 +283,7 @@ class AppLocalizationsId extends AppLocalizations {
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
@override
String get aboutTranslators => 'Translators';
String get aboutTranslators => 'Penerjemah';
@override
String get aboutSpecialThanks => 'Terima Kasih Khusus';
@@ -311,19 +311,19 @@ class AppLocalizationsId extends AppLocalizations {
'Sarankan fitur baru untuk aplikasi';
@override
String get aboutTelegramChannel => 'Telegram Channel';
String get aboutTelegramChannel => 'Saluran Telegram';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
String get aboutTelegramChannelSubtitle => 'Pengumuman dan pembaruan';
@override
String get aboutTelegramChat => 'Telegram Community';
String get aboutTelegramChat => 'Komunitas Telegram';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
String get aboutTelegramChatSubtitle => 'Berbincang dengan pengguna lain';
@override
String get aboutSocial => 'Social';
String get aboutSocial => 'Sosial';
@override
String get aboutApp => 'Aplikasi';
@@ -341,7 +341,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
'Pencipta I Don\'t Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!';
@override
String get aboutDabMusic => 'DAB Music';
@@ -355,7 +355,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
'Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!';
@override
String get aboutAppDescription =>
@@ -456,7 +456,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
'iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.';
@override
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
@@ -593,7 +593,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
return '$count trek dari CSV';
}
@override
@@ -613,7 +613,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
return '\"$trackName\" sudah ada di perpustakaan Anda';
}
@override
@@ -691,6 +691,17 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
@override
String get errorUrlNotRecognized => 'Link tidak dikenali';
@override
String get errorUrlNotRecognizedMessage =>
'Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.';
@override
String get errorUrlFetchFailed =>
'Gagal memuat konten dari link ini. Silakan coba lagi.';
@override
String errorMissingExtensionSource(String item) {
return 'Tidak dapat memuat $item: sumber ekstensi tidak ada';
@@ -755,15 +766,22 @@ class AppLocalizationsId extends AppLocalizations {
String get filenameFormat => 'Format Nama File';
@override
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Aktifkan tag format untuk padding nomor lagu dan pola tanggal';
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganizationNone => 'Tidak ada';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'Berdasarkan Artis';
@@ -1343,10 +1361,10 @@ class AppLocalizationsId extends AppLocalizations {
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube';
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube';
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1684,8 +1702,8 @@ class AppLocalizationsId extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@@ -2134,10 +2152,58 @@ class AppLocalizationsId extends AppLocalizations {
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Buat';
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get collectionFoldersTitle => 'Folder saya';
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@@ -2146,172 +2212,171 @@ class AppLocalizationsId extends AppLocalizations {
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlist';
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Tambahkan ke playlist';
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Buat playlist';
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'Belum ada playlist';
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Buat playlist untuk mulai mengategorikan lagu';
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count lagu',
one: '1 lagu',
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Ditambahkan ke \"$playlistName\"';
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Sudah ada di \"$playlistName\"';
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist berhasil dibuat';
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Nama playlist';
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi';
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Ubah nama playlist';
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Hapus playlist';
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Hapus \"$playlistName\" beserta semua lagunya?';
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist dihapus';
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Nama playlist diperbarui';
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist masih kosong';
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + di lagu untuk menyimpan yang ingin diunduh nanti';
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Folder Loved masih kosong';
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love di lagu untuk menyimpan favoritmu';
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist masih kosong';
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Tekan lama tombol + pada lagu untuk menambahkannya ke sini';
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Hapus dari playlist';
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Hapus dari folder';
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" dihapus';
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" ditambahkan ke Loved';
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" dihapus dari Loved';
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" ditambahkan ke Wishlist';
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" dihapus dari Wishlist';
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Tambahkan ke Loved';
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Hapus dari Loved';
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist';
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist';
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Ubah gambar sampul';
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Hapus gambar sampul';
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
other: 'tracks',
one: 'track',
);
return 'Bagikan $count $_temp0';
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan';
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
other: 'tracks',
one: 'track',
);
return 'Konversi $count $_temp0';
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible =>
'Tidak ada trek yang dapat dikonversi dipilih';
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Konversi Massal';
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
@@ -2322,20 +2387,20 @@ class AppLocalizationsId extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
other: 'tracks',
one: 'track',
);
return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Mengonversi $current dari $total...';
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Berhasil mengonversi $success dari $total trek ke $format';
return 'Converted $success of $total tracks to $format';
}
@override
+145 -79
View File
@@ -15,7 +15,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get navHome => 'ホーム';
@override
String get navLibrary => 'Library';
String get navLibrary => 'ライブラリ';
@override
String get navSettings => '設定';
@@ -160,7 +160,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String optionsConcurrentParallel(int count) {
return '$count parallel downloads';
return '$count 件の分割ダウンロード';
}
@override
@@ -683,6 +683,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get errorNoTracksFound => 'トラックがありません';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return '$item を読み込めません: 拡張ソースがありません';
@@ -756,6 +767,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get folderOrganizationNone => '構成がありません';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'アーティスト別';
@@ -1111,7 +1129,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackLyricsLoadFailed => '歌詞の読み込みに失敗しました';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
String get trackEmbedLyrics => '歌詞を埋め込む';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@@ -1325,10 +1343,10 @@ class AppLocalizationsJa extends AppLocalizations {
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
@override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1375,20 +1393,20 @@ class AppLocalizationsJa extends AppLocalizations {
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
String get settingsAutoExportFailed => 'ダウンロードの自動エクスポートに失敗しました';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
String get settingsDownloadNetwork => 'ダウンロードネットワーク';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
String get settingsDownloadNetworkAny => 'Wi-Fi + モバイルデータ';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
String get settingsDownloadNetworkWifiOnly => 'Wi-Fi のみ';
@override
String get settingsDownloadNetworkSubtitle =>
@@ -1419,7 +1437,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get albumFolderYearAlbumSubtitle => 'アルバム/[2005] アルバム名/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
String get albumFolderArtistAlbumSingles => 'アーティスト / アルバム + シングル';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
@@ -1485,7 +1503,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
String get recentShowAllDownloads => 'すべてのダウンロードを表示';
@override
String recentPlaylistInfo(String name) {
@@ -1559,10 +1577,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get discographyFailedToFetch => '一部のアルバムの取得に失敗しました';
@override
String get sectionStorageAccess => 'Storage Access';
String get sectionStorageAccess => 'ストレージアクセス';
@override
String get allFilesAccess => 'All Files Access';
String get allFilesAccess => 'すべてのファイルへのアクセス';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@@ -1583,35 +1601,35 @@ class AppLocalizationsJa extends AppLocalizations {
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
String get settingsLocalLibrary => 'ローカルライブラリ';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
String get settingsCache => 'ストレージとキャッシュ';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
String get libraryTitle => 'ローカルライブラリ';
@override
String get libraryScanSettings => 'Scan Settings';
String get libraryScanSettings => 'スキャン設定';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
String get libraryEnableLocalLibrary => 'ローカルライブラリを有効';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
String get libraryFolder => 'ライブラリのフォルダ';
@override
String get libraryFolderHint => 'Tap to select folder';
String get libraryFolderHint => 'タップでフォルダを選択';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@@ -1621,13 +1639,13 @@ class AppLocalizationsJa extends AppLocalizations {
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
String get libraryActions => 'アクション';
@override
String get libraryScan => 'Scan Library';
String get libraryScan => 'ライブラリをスキャン';
@override
String get libraryScanSubtitle => 'Scan for audio files';
String get libraryScanSubtitle => 'オーディオファイルをスキャン';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@@ -1640,20 +1658,20 @@ class AppLocalizationsJa extends AppLocalizations {
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
String get libraryClear => 'ライブラリを消去';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
String get libraryClearConfirmTitle => 'ライブラリを消去';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
String get libraryAbout => 'ローカルライブラリについて';
@override
String get libraryAboutDescription =>
@@ -1672,14 +1690,14 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
return '最終スキャン: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
String get libraryScanning => 'スキャン中...';
@override
String libraryScanProgress(String progress, int total) {
@@ -1687,7 +1705,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get libraryInLibrary => 'In Library';
String get libraryInLibrary => 'ライブラリ内';
@override
String libraryRemovedMissingFiles(int count) {
@@ -1698,7 +1716,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
String get libraryStorageAccessRequired => 'ストレージアクセスが必要です';
@override
String get libraryStorageAccessMessage =>
@@ -1708,37 +1726,37 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
String get librarySourceDownloaded => 'ダウンロード済み';
@override
String get librarySourceLocal => 'Local';
String get librarySourceLocal => 'ローカル';
@override
String get libraryFilterAll => 'All';
String get libraryFilterAll => 'すべて';
@override
String get libraryFilterDownloaded => 'Downloaded';
String get libraryFilterDownloaded => 'ダウンロード済み';
@override
String get libraryFilterLocal => 'Local';
String get libraryFilterLocal => 'ローカル';
@override
String get libraryFilterTitle => 'Filters';
String get libraryFilterTitle => 'フィルター';
@override
String get libraryFilterReset => 'Reset';
String get libraryFilterReset => 'リセット';
@override
String get libraryFilterApply => 'Apply';
String get libraryFilterApply => '適用';
@override
String get libraryFilterSource => 'Source';
String get libraryFilterSource => 'ソース';
@override
String get libraryFilterQuality => 'Quality';
String get libraryFilterQuality => '品質';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
String get libraryFilterQualityHiRes => 'ハイレゾ (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@@ -1747,7 +1765,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
String get libraryFilterFormat => '形式';
@override
String get libraryFilterSort => 'Sort';
@@ -1766,8 +1784,8 @@ class AppLocalizationsJa extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
other: '$count 分前',
one: '1 分前',
);
return '$_temp0';
}
@@ -1777,14 +1795,14 @@ class AppLocalizationsJa extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
other: '$count 時間前',
one: '1 時間前',
);
return '$_temp0';
}
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
String get tutorialWelcomeTitle => 'SpotiFLAC へようこそ!';
@override
String get tutorialWelcomeDesc =>
@@ -1810,14 +1828,14 @@ class AppLocalizationsJa extends AppLocalizations {
'There are two easy ways to find music you want to download.';
@override
String get tutorialDownloadTitle => 'Downloading Music';
String get tutorialDownloadTitle => '音楽をダウンロード中';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialLibraryTitle => 'Your Library';
String get tutorialLibraryTitle => 'あなたのライブラリ';
@override
String get tutorialLibraryDesc =>
@@ -1836,7 +1854,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
String get tutorialExtensionsTitle => '拡張';
@override
String get tutorialExtensionsDesc =>
@@ -1877,7 +1895,7 @@ class AppLocalizationsJa extends AppLocalizations {
'You\'re all set! Start downloading your favorite music now.';
@override
String get libraryForceFullScan => 'Force Full Scan';
String get libraryForceFullScan => '強制フルスキャン';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@@ -1898,10 +1916,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
String get cacheTitle => 'ストレージとキャッシュ';
@override
String get cacheSummaryTitle => 'Cache overview';
String get cacheSummaryTitle => 'キャッシュの概要';
@override
String get cacheSummarySubtitle =>
@@ -1913,34 +1931,34 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get cacheSectionStorage => 'Cached Data';
String get cacheSectionStorage => 'キャッシュ済みデータ';
@override
String get cacheSectionMaintenance => 'Maintenance';
String get cacheSectionMaintenance => 'メンテナンス';
@override
String get cacheAppDirectory => 'App cache directory';
String get cacheAppDirectory => 'アプリキャッシュのディレクトリ';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
String get cacheTempDirectory => '一時ディレクトリ';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
String get cacheCoverImage => 'カバー画像のキャッシュ';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
String get cacheLibraryCover => 'ライブラリのカバーキャッシュ';
@override
String get cacheLibraryCoverDesc =>
@@ -1965,7 +1983,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
String get cacheNoData => 'キャッシュデータはありません';
@override
String cacheSizeWithFiles(String size, int count) {
@@ -1979,16 +1997,16 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String cacheEntries(int count) {
return '$count entries';
return '$count 個のエントリ';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
return '消去済み: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
String get cacheClearConfirmTitle => 'キャッシュを消去しますか?';
@override
String cacheClearConfirmMessage(String target) {
@@ -1996,17 +2014,17 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
String get cacheClearAllConfirmTitle => 'すべてのキャッシュを消去しますか?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
String get cacheClearAll => 'すべてのキャッシュを消去';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
String get cacheCleanupUnused => '未使用のデータを削除';
@override
String get cacheCleanupUnusedSubtitle =>
@@ -2018,16 +2036,16 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get cacheRefreshStats => 'Refresh stats';
String get cacheRefreshStats => '状態を更新';
@override
String get trackSaveCoverArt => 'Save Cover Art';
String get trackSaveCoverArt => 'カバー画像を保存';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
String get trackSaveLyrics => '歌詞を保存 (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@@ -2043,7 +2061,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
String get trackEditMetadata => 'メタデータを編集';
@override
String trackCoverSaved(String fileName) {
@@ -2072,26 +2090,26 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
return '失敗: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
String get trackConvertFormat => '変換の形式';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle => 'MP3 または Opus に変換';
@override
String get trackConvertTitle => 'Convert Audio';
String get trackConvertTitle => 'オーディオを変換';
@override
String get trackConvertTargetFormat => 'Target Format';
String get trackConvertTargetFormat => 'ターゲットの形式';
@override
String get trackConvertBitrate => 'Bitrate';
String get trackConvertBitrate => 'ビットレート';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
String get trackConvertConfirmTitle => '変換を確認';
@override
String trackConvertConfirmMessage(
@@ -2103,7 +2121,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get trackConvertConverting => 'Converting audio...';
String get trackConvertConverting => 'オーディオを変換中...';
@override
String trackConvertSuccess(String format) {
@@ -2111,7 +2129,55 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get trackConvertFailed => 'Conversion failed';
String get trackConvertFailed => '変換に失敗しました';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
File diff suppressed because it is too large. Load diff
+66
View File
@@ -688,6 +688,17 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -2126,6 +2144,54 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
+70 -4
View File
@@ -356,7 +356,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@@ -688,6 +688,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1809,7 +1827,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2126,6 +2144,54 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2705,7 +2771,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get aboutAppDescription =>
'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.';
'Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.';
@override
String get artistAlbums => 'Álbuns';
@@ -4147,7 +4213,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer';
@override
String get tutorialWelcomeTip3 =>
+182 -107
View File
@@ -67,7 +67,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get settingsAbout => 'О программе';
@override
String get downloadTitle => 'Скачивание';
String get downloadTitle => 'Скачать';
@override
String get downloadAskQualitySubtitle =>
@@ -146,11 +146,11 @@ class AppLocalizationsRu extends AppLocalizations {
'Использование только встроенных провайдеров';
@override
String get optionsEmbedLyrics => 'Вставить текст песни';
String get optionsEmbedLyrics => 'Вписать текст песни';
@override
String get optionsEmbedLyricsSubtitle =>
'Вставить синхронизированные тексты в FLAC файлы';
'Вписать синхронизированные тексты во FLAC файлы';
@override
String get optionsMaxQualityCover => 'Максимальное качество обложки';
@@ -337,7 +337,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!';
'Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!';
@override
String get aboutSachinsenalDesc =>
@@ -601,7 +601,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String csvImportTracks(int count) {
return '$count треков из CSV';
return '$count трек(-ов) из CSV';
}
@override
@@ -702,6 +702,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get errorNoTracksFound => 'Треки не найдены';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Невозможно загрузить $item: отсутствует источник расширения';
@@ -766,15 +777,22 @@ class AppLocalizationsRu extends AppLocalizations {
String get filenameFormat => 'Формат имени файла';
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
String get filenameShowAdvancedTags => 'Показать расширенные теги';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
'Включить форматированные теги для отслеживания заполнения и шаблонов дат';
@override
String get folderOrganizationNone => 'Без организации';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'По исполнителю';
@@ -983,7 +1001,7 @@ class AppLocalizationsRu extends AppLocalizations {
'Выберите как сохранить тексты песен при скачивании';
@override
String get lyricsModeEmbed => 'Вставить в файл';
String get lyricsModeEmbed => 'Вписать в файл';
@override
String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC';
@@ -999,7 +1017,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get lyricsModeBoth => 'Оба варианта';
@override
String get lyricsModeBothSubtitle => 'Вставить и сохранить файл .lrc';
String get lyricsModeBothSubtitle => 'Вписать и сохранить .lrc файл';
@override
String get sectionColor => 'Цвет';
@@ -1138,7 +1156,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
@override
String get trackEmbedLyrics => 'Вставить текст песни';
String get trackEmbedLyrics => 'Вписать текст песни';
@override
String get trackLyricsEmbedded => 'Текст успешно добавлен';
@@ -1361,10 +1379,10 @@ class AppLocalizationsRu extends AppLocalizations {
'YouTube обеспечивает только звук с потерями(Lossy).';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1383,7 +1401,8 @@ class AppLocalizationsRu extends AppLocalizations {
'Использовать исполнителя альбома для папок';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
String get downloadUsePrimaryArtistOnly =>
'Основной исполнитель только для папок';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
@@ -1391,7 +1410,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
'Полная строка исполнителя, используемая для имени папки';
@override
String get downloadSelectQuality => 'Выбор качества';
@@ -1423,7 +1442,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get settingsDownloadNetwork => 'Сеть для скачивания';
@override
String get settingsDownloadNetworkAny => 'WiFi и мобильная сеть';
String get settingsDownloadNetworkAny => 'WiFi и Мобильная сеть';
@override
String get settingsDownloadNetworkWifiOnly => 'Только WiFi';
@@ -1712,8 +1731,10 @@ class AppLocalizationsRu extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'треков',
many: 'треков',
few: 'трека',
one: 'трек',
);
return '$_temp0';
}
@@ -1800,7 +1821,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get libraryFilterQualityCD => 'CD (16 бит)';
@override
String get libraryFilterQualityLossy => 'С потерями';
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Формат';
@@ -1904,7 +1925,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Просмотрите вкладку Магазина, чтобы найти полезные расширения';
@override
String get tutorialExtensionsTip2 =>
@@ -1912,14 +1933,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
'Получайте тексты песен, улучшенные метаданные и другие возможности';
@override
String get tutorialSettingsTitle => 'Настройте приложение под себя';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
'Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.';
@override
String get tutorialSettingsTip1 =>
@@ -1944,11 +1965,11 @@ class AppLocalizationsRu extends AppLocalizations {
'Пересканировать все файлы, игнорировать кэш';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
String get cleanupOrphanedDownloads => 'Очистка отложенных скачиваний';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
'Удалить историю записи для файлов, которых больше не существует';
@override
String cleanupOrphanedDownloadsResult(int count) {
@@ -1956,7 +1977,7 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
String get cleanupOrphanedDownloadsNone => 'Записей без описания не найдено';
@override
String get cacheTitle => 'Хранилище и кэш';
@@ -1966,11 +1987,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
'Очистка кэша не приведет к удалению загруженных музыкальных файлов.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
return 'Приблизительное использование кэша: $size';
}
@override
@@ -1984,42 +2005,42 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
'HTTP-ответы, данные WebView и другие временные данные приложения.';
@override
String get cacheTempDirectory => 'Temporary directory';
String get cacheTempDirectory => 'Временная директория';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
'Временные файлы из загрузок и аудио конвертации.';
@override
String get cacheCoverImage => 'Cover image cache';
String get cacheCoverImage => 'Кэш обложек';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
'Скачанный альбом и трек обложки. Будет заново скачан после просмотра.';
@override
String get cacheLibraryCover => 'Library cover cache';
String get cacheLibraryCover => 'Кэш обложек библиотеки';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
'Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.';
@override
String get cacheExploreFeed => 'Explore feed cache';
String get cacheExploreFeed => 'Просмотреть кэш ленты';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
'Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.';
@override
String get cacheTrackLookup => 'Track lookup cache';
String get cacheTrackLookup => 'Отслеживать кэш поиска';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
'Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.';
@override
String get cacheCleanupUnusedDesc =>
@@ -2040,7 +2061,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheEntries(int count) {
return '$count entries';
return '$count записей';
}
@override
@@ -2053,7 +2074,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
return 'Это очистит кэш для $target. Загруженные музыкальные файлы не будут удалены.';
}
@override
@@ -2075,7 +2096,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
return 'Очистка завершена: $downloadCount потерянных загрузок, $libraryCount отсутствующих записей в библиотеке';
}
@override
@@ -2095,14 +2116,14 @@ class AppLocalizationsRu extends AppLocalizations {
'Получить и сохранить текст песни в формате .lrc';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
String get trackSaveLyricsProgress => 'Сохранение текста...';
@override
String get trackReEnrich => 'Re-enrich';
String get trackReEnrich => 'Обновить';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
'Поиск в сети метаданных и встраивание в файл';
@override
String get trackEditMetadata => 'Редактировать метаданные';
@@ -2121,13 +2142,13 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
String get trackReEnrichProgress => 'Обновление метаданных...';
@override
String get trackReEnrichSearching => 'Поиск метаданных в сети...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
String get trackReEnrichSuccess => 'Метаданные успешно обновлены';
@override
String get trackReEnrichFfmpegFailed =>
@@ -2139,22 +2160,22 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
String get trackConvertFormat => 'Convert Format';
String get trackConvertFormat => 'Переконвертировать формат';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle => 'Конвертировать в MP3 или Opus';
@override
String get trackConvertTitle => 'Convert Audio';
String get trackConvertTitle => 'Конвертировать аудио';
@override
String get trackConvertTargetFormat => 'Target Format';
String get trackConvertTargetFormat => 'Целевой формат';
@override
String get trackConvertBitrate => 'Bitrate';
String get trackConvertBitrate => 'Битрейт';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
String get trackConvertConfirmTitle => 'Подтвердить конвертацию';
@override
String trackConvertConfirmMessage(
@@ -2162,177 +2183,229 @@ class AppLocalizationsRu extends AppLocalizations {
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
}
@override
String get trackConvertConverting => 'Converting audio...';
String get trackConvertConverting => 'Конвертация аудио...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
return 'Успешно конвертировано в $format';
}
@override
String get trackConvertFailed => 'Conversion failed';
String get trackConvertFailed => 'Ошибка конвертации';
@override
String get actionCreate => 'Create';
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get collectionFoldersTitle => 'My folders';
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String get collectionWishlist => 'Wishlist';
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String get collectionLoved => 'Loved';
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String get collectionPlaylists => 'Playlists';
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get collectionPlaylist => 'Playlist';
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String get collectionAddToPlaylist => 'Add to playlist';
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String get collectionCreatePlaylist => 'Create playlist';
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Создать';
@override
String get collectionFoldersTitle => 'Мои папки';
@override
String get collectionWishlist => 'Список желаемого';
@override
String get collectionLoved => 'Любимые';
@override
String get collectionPlaylists => 'Плейлисты';
@override
String get collectionPlaylist => 'Плейлист';
@override
String get collectionAddToPlaylist => 'Добавить в плейлист';
@override
String get collectionCreatePlaylist => 'Создать плейлист';
@override
String get collectionNoPlaylistsYet => 'Плейлисты отсутствуют';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
'Создайте плейлист, чтобы начать классифицировать треки';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
other: '$count треков',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
return 'Добавлено в \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
return 'Уже в \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
String get collectionPlaylistCreated => 'Плейлист создан';
@override
String get collectionPlaylistNameHint => 'Playlist name';
String get collectionPlaylistNameHint => 'Название плейлиста';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
String get collectionPlaylistNameRequired => 'Имя плейлиста обязательно';
@override
String get collectionRenamePlaylist => 'Rename playlist';
String get collectionRenamePlaylist => 'Переименовать плейлист';
@override
String get collectionDeletePlaylist => 'Delete playlist';
String get collectionDeletePlaylist => 'Удалить плейлист';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
return 'Удалить \"$playlistName\" и все треки внутри него?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
String get collectionPlaylistDeleted => 'Плейлист удалён';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
String get collectionPlaylistRenamed => 'Плейлист переименован';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
String get collectionWishlistEmptyTitle => 'Список желаний пуст';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
'Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
String get collectionLovedEmptyTitle => 'Папка Любимые пуста';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
'Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
String get collectionPlaylistEmptyTitle => 'Плейлист пуст';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
'Удерживайте + на любом треке, чтобы добавить его сюда';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
String get collectionRemoveFromPlaylist => 'Удалить из плейлиста';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
String get collectionRemoveFromFolder => 'Убрать из папки';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
return '\"$trackName\" удалён';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
return '\"$trackName\" добавлен в Любимые';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
return '\"$trackName\" удалено из Любимых';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
return '\"$trackName\" добавлен в список желаний';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
return '\"$trackName\" удалён из списка желаний';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
String get trackOptionAddToLoved => 'Добавить в Любимое';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
String get trackOptionRemoveFromLoved => 'Исключить из Любимых';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
String get trackOptionAddToWishlist => 'Добавить в список желаний';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
String get trackOptionRemoveFromWishlist => 'Удалить из списка желаний';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
String get collectionPlaylistChangeCover => 'Изменить обложку';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
String get collectionPlaylistRemoveCover => 'Удалить обложку';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'треков',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Share $count $_temp0';
return 'Отправить $count $_temp0';
}
@override
@@ -2343,17 +2416,19 @@ class AppLocalizationsRu extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'треков',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Convert $count $_temp0';
return 'Конвертировать $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
String get selectionConvertNoConvertible => 'Не выбраны конвертируемые треки';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
String get selectionBatchConvertConfirmTitle => 'Пакетная конвертация';
@override
String selectionBatchConvertConfirmMessage(
@@ -2372,12 +2447,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
return 'Конвертация $current из $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
return 'Конвертировано $success треков $total в $format';
}
@override
@@ -2387,7 +2462,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Для папок исполнителей используется исполнитель альбома, если он указан';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
+68 -2
View File
@@ -361,7 +361,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
'Spotify şarkılarını Tidal ve Qobuz\'den yüksek kalitede indir.';
@override
String get artistAlbums => 'Albümler';
@@ -693,6 +693,17 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get errorNoTracksFound => 'Parça bulunamadı';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return '$item yüklenemedi: Eksik eklenti kaynağı';
@@ -766,6 +777,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get folderOrganizationNone => 'Organizasyon yok';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'Sanatçıya Göre';
@@ -1821,7 +1839,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Tidal, Qobuz veya Deezer\'den FLAC kalitesinde ses alın';
@override
String get tutorialWelcomeTip3 =>
@@ -2138,6 +2156,54 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
File diff suppressed because it is too large. Load diff
+583 -281
View File
File diff suppressed because it is too large. Load diff
+107 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
+2 -2
View File
@@ -402,7 +402,7 @@
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1005,7 +1005,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
+3 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.",
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Integrado",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extensión",
"@providerExtension": {
@@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+80 -68
View File
@@ -9,7 +9,7 @@
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navLibrary": "Library",
"navLibrary": "Pustaka",
"@navLibrary": {
"description": "Bottom navigation - Library tab"
},
@@ -49,7 +49,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historySearchHint": "Search history...",
"historySearchHint": "Cari riwayat...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
@@ -125,7 +125,7 @@
"@appearanceHistoryViewList": {
"description": "List layout option"
},
"appearanceHistoryViewGrid": "Grid",
"appearanceHistoryViewGrid": "Kisi",
"@appearanceHistoryViewGrid": {
"description": "Grid layout option"
},
@@ -154,7 +154,7 @@
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
},
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallback": "Cadangan Otomatis",
"@optionsAutoFallback": {
"description": "Auto-retry with other services"
},
@@ -267,7 +267,7 @@
"@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting"
},
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsConfigured": "ID Klien: {clientId}...",
"@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview",
"placeholders": {
@@ -284,7 +284,7 @@
"@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement"
},
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
"optionsSpotifyDeprecationWarning": "Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.",
"@optionsSpotifyDeprecationWarning": {
"description": "Warning about Spotify API deprecation"
},
@@ -358,7 +358,7 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Translators",
"aboutTranslators": "Penerjemah",
"@aboutTranslators": {
"description": "Section for translators"
},
@@ -394,23 +394,23 @@
"@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request"
},
"aboutTelegramChannel": "Telegram Channel",
"aboutTelegramChannel": "Saluran Telegram",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"aboutTelegramChannelSubtitle": "Pengumuman dan pembaruan",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"aboutTelegramChat": "Komunitas Telegram",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"aboutTelegramChatSubtitle": "Berbincang dengan pengguna lain",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"aboutSocial": "Sosial",
"@aboutSocial": {
"description": "Section for social links"
},
@@ -430,7 +430,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!",
"aboutSjdonadoDesc": "Pencipta I Don't Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!",
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
@@ -446,7 +446,7 @@
"@aboutSpotiSaver": {
"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"
},
"aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!",
"aboutSpotiSaverDesc": "Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!",
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
@@ -579,7 +579,7 @@
"@setupIosEmptyFolderWarning": {
"description": "iOS folder selection warning"
},
"setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.",
"setupIcloudNotSupported": "iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.",
"@setupIcloudNotSupported": {
"description": "Error when user selects iCloud Drive on iOS"
},
@@ -742,7 +742,7 @@
"description": "Dialog title - import CSV playlist"
},
"dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?",
"csvImportTracks": "{count} tracks from CSV",
"csvImportTracks": "{count} trek dari CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
@@ -786,7 +786,7 @@
}
}
},
"snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library",
"snackbarAlreadyInLibrary": "\"{trackName}\" sudah ada di perpustakaan Anda",
"@snackbarAlreadyInLibrary": {
"description": "Snackbar - track already exists in local library",
"placeholders": {
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link tidak dikenali",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Gagal memuat konten dari link ini. Silakan coba lagi.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -991,11 +1003,11 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Aktifkan tag format untuk padding nomor lagu dan pola tanggal",
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
@@ -1757,11 +1769,11 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "Bitrate Opus YouTube",
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "Bitrate MP3 YouTube",
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
@@ -2214,7 +2226,7 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{trek} other{trek}}",
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
@@ -2808,11 +2820,11 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Buat",
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "Folder saya",
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
@@ -2824,7 +2836,7 @@
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlist",
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
@@ -2832,23 +2844,23 @@
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Tambahkan ke playlist",
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Buat playlist",
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "Belum ada playlist",
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Buat playlist untuk mulai mengategorikan lagu",
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
@@ -2857,7 +2869,7 @@
}
}
},
"collectionAddedToPlaylist": "Ditambahkan ke \"{playlistName}\"",
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
@@ -2866,7 +2878,7 @@
}
}
},
"collectionAlreadyInPlaylist": "Sudah ada di \"{playlistName}\"",
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
@@ -2875,27 +2887,27 @@
}
}
},
"collectionPlaylistCreated": "Playlist berhasil dibuat",
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Nama playlist",
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Nama playlist wajib diisi",
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Ubah nama playlist",
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Hapus playlist",
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Hapus \"{playlistName}\" beserta semua lagunya?",
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
@@ -2904,47 +2916,47 @@
}
}
},
"collectionPlaylistDeleted": "Playlist dihapus",
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Nama playlist diperbarui",
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist masih kosong",
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + di lagu untuk menyimpan yang ingin diunduh nanti",
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Folder Loved masih kosong",
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love di lagu untuk menyimpan favoritmu",
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist masih kosong",
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Tekan lama tombol + pada lagu untuk menambahkannya ke sini",
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Hapus dari playlist",
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Hapus dari folder",
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" dihapus",
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
@@ -2953,7 +2965,7 @@
}
}
},
"collectionAddedToLoved": "\"{trackName}\" ditambahkan ke Loved",
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
@@ -2962,7 +2974,7 @@
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" dihapus dari Loved",
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
@@ -2971,7 +2983,7 @@
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" ditambahkan ke Wishlist",
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
@@ -2980,7 +2992,7 @@
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" dihapus dari Wishlist",
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
@@ -2989,31 +3001,31 @@
}
}
},
"trackOptionAddToLoved": "Tambahkan ke Loved",
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Hapus dari Loved",
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Tambahkan ke Wishlist",
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Hapus dari Wishlist",
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Ubah gambar sampul",
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Hapus gambar sampul",
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}",
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
@@ -3022,11 +3034,11 @@
}
}
},
"selectionShareNoFiles": "Tidak ada file yang dapat dibagikan",
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Konversi {count} {count, plural, =1{trek} other{trek}}",
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
@@ -3035,15 +3047,15 @@
}
}
},
"selectionConvertNoConvertible": "Tidak ada trek yang dapat dikonversi dipilih",
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Konversi Massal",
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.",
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
@@ -3058,7 +3070,7 @@
}
}
},
"selectionBatchConvertProgress": "Mengonversi {current} dari {total}...",
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
@@ -3070,7 +3082,7 @@
}
}
},
"selectionBatchConvertSuccess": "Berhasil mengonversi {success} dari {total} trek ke {format}",
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
@@ -3102,4 +3114,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+378 -76
View File
@@ -9,7 +9,7 @@
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navLibrary": "Library",
"navLibrary": "ライブラリ",
"@navLibrary": {
"description": "Bottom navigation - Library tab"
},
@@ -198,7 +198,7 @@
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"optionsConcurrentParallel": "{count} 件の分割ダウンロード",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "構成がありません",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1455,7 +1463,7 @@
"@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails"
},
"trackEmbedLyrics": "Embed Lyrics",
"trackEmbedLyrics": "歌詞を埋め込む",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus のビットレート",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 のビットレート",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "ダウンロード前に確認する",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -1805,7 +1821,7 @@
"@queueClearAllMessage": {
"description": "Clear queue confirmation"
},
"settingsAutoExportFailed": "Auto-export failed downloads",
"settingsAutoExportFailed": "ダウンロードの自動エクスポートに失敗しました",
"@settingsAutoExportFailed": {
"description": "Setting toggle for auto-export"
},
@@ -1813,15 +1829,15 @@
"@settingsAutoExportFailedSubtitle": {
"description": "Subtitle for auto-export setting"
},
"settingsDownloadNetwork": "Download Network",
"settingsDownloadNetwork": "ダウンロードネットワーク",
"@settingsDownloadNetwork": {
"description": "Setting for network type preference"
},
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
"settingsDownloadNetworkAny": "Wi-Fi + モバイルデータ",
"@settingsDownloadNetworkAny": {
"description": "Network option - use any connection"
},
"settingsDownloadNetworkWifiOnly": "WiFi Only",
"settingsDownloadNetworkWifiOnly": "Wi-Fi のみ",
"@settingsDownloadNetworkWifiOnly": {
"description": "Network option - only use WiFi"
},
@@ -1861,7 +1877,7 @@
"@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example"
},
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"albumFolderArtistAlbumSingles": "アーティスト / アルバム + シングル",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
@@ -1942,7 +1958,7 @@
"@recentEmpty": {
"description": "Empty state text for recent access list"
},
"recentShowAllDownloads": "Show All Downloads",
"recentShowAllDownloads": "すべてのダウンロードを表示",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
@@ -2074,11 +2090,11 @@
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
},
"sectionStorageAccess": "Storage Access",
"sectionStorageAccess": "ストレージアクセス",
"@sectionStorageAccess": {
"description": "Section header for storage access settings"
},
"allFilesAccess": "All Files Access",
"allFilesAccess": "すべてのファイルへのアクセス",
"@allFilesAccess": {
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
},
@@ -2102,7 +2118,7 @@
"@allFilesAccessDisabledMessage": {
"description": "Snackbar message when user disables all files access"
},
"settingsLocalLibrary": "Local Library",
"settingsLocalLibrary": "ローカルライブラリ",
"@settingsLocalLibrary": {
"description": "Settings menu item - local library"
},
@@ -2110,7 +2126,7 @@
"@settingsLocalLibrarySubtitle": {
"description": "Subtitle for local library settings"
},
"settingsCache": "Storage & Cache",
"settingsCache": "ストレージとキャッシュ",
"@settingsCache": {
"description": "Settings menu item - cache management"
},
@@ -2118,15 +2134,15 @@
"@settingsCacheSubtitle": {
"description": "Subtitle for cache management menu"
},
"libraryTitle": "Local Library",
"libraryTitle": "ローカルライブラリ",
"@libraryTitle": {
"description": "Library settings page title"
},
"libraryScanSettings": "Scan Settings",
"libraryScanSettings": "スキャン設定",
"@libraryScanSettings": {
"description": "Section header for scan settings"
},
"libraryEnableLocalLibrary": "Enable Local Library",
"libraryEnableLocalLibrary": "ローカルライブラリを有効",
"@libraryEnableLocalLibrary": {
"description": "Toggle to enable library scanning"
},
@@ -2134,11 +2150,11 @@
"@libraryEnableLocalLibrarySubtitle": {
"description": "Subtitle for enable toggle"
},
"libraryFolder": "Library Folder",
"libraryFolder": "ライブラリのフォルダ",
"@libraryFolder": {
"description": "Folder selection setting"
},
"libraryFolderHint": "Tap to select folder",
"libraryFolderHint": "タップでフォルダを選択",
"@libraryFolderHint": {
"description": "Placeholder when no folder selected"
},
@@ -2150,15 +2166,15 @@
"@libraryShowDuplicateIndicatorSubtitle": {
"description": "Subtitle for duplicate indicator toggle"
},
"libraryActions": "Actions",
"libraryActions": "アクション",
"@libraryActions": {
"description": "Section header for library actions"
},
"libraryScan": "Scan Library",
"libraryScan": "ライブラリをスキャン",
"@libraryScan": {
"description": "Button to start library scan"
},
"libraryScanSubtitle": "Scan for audio files",
"libraryScanSubtitle": "オーディオファイルをスキャン",
"@libraryScanSubtitle": {
"description": "Subtitle for scan button"
},
@@ -2174,7 +2190,7 @@
"@libraryCleanupMissingFilesSubtitle": {
"description": "Subtitle for cleanup button"
},
"libraryClear": "Clear Library",
"libraryClear": "ライブラリを消去",
"@libraryClear": {
"description": "Button to clear all library entries"
},
@@ -2182,7 +2198,7 @@
"@libraryClearSubtitle": {
"description": "Subtitle for clear button"
},
"libraryClearConfirmTitle": "Clear Library",
"libraryClearConfirmTitle": "ライブラリを消去",
"@libraryClearConfirmTitle": {
"description": "Dialog title for clear confirmation"
},
@@ -2190,7 +2206,7 @@
"@libraryClearConfirmMessage": {
"description": "Dialog message for clear confirmation"
},
"libraryAbout": "About Local Library",
"libraryAbout": "ローカルライブラリについて",
"@libraryAbout": {
"description": "Section header for about info"
},
@@ -2198,7 +2214,16 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryLastScanned": "Last scanned: {time}",
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "最終スキャン: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
"placeholders": {
@@ -2211,7 +2236,7 @@
"@libraryLastScannedNever": {
"description": "Shown when library has never been scanned"
},
"libraryScanning": "Scanning...",
"libraryScanning": "スキャン中...",
"@libraryScanning": {
"description": "Status during scan"
},
@@ -2227,7 +2252,7 @@
}
}
},
"libraryInLibrary": "In Library",
"libraryInLibrary": "ライブラリ内",
"@libraryInLibrary": {
"description": "Badge shown on tracks that exist in local library"
},
@@ -2244,7 +2269,7 @@
"@libraryCleared": {
"description": "Snackbar after clearing library"
},
"libraryStorageAccessRequired": "Storage Access Required",
"libraryStorageAccessRequired": "ストレージアクセスが必要です",
"@libraryStorageAccessRequired": {
"description": "Dialog title for storage permission"
},
@@ -2256,47 +2281,47 @@
"@libraryFolderNotExist": {
"description": "Error when folder doesn't exist"
},
"librarySourceDownloaded": "Downloaded",
"librarySourceDownloaded": "ダウンロード済み",
"@librarySourceDownloaded": {
"description": "Badge for tracks downloaded via SpotiFLAC"
},
"librarySourceLocal": "Local",
"librarySourceLocal": "ローカル",
"@librarySourceLocal": {
"description": "Badge for tracks from local library scan"
},
"libraryFilterAll": "All",
"libraryFilterAll": "すべて",
"@libraryFilterAll": {
"description": "Filter chip - show all library items"
},
"libraryFilterDownloaded": "Downloaded",
"libraryFilterDownloaded": "ダウンロード済み",
"@libraryFilterDownloaded": {
"description": "Filter chip - show only downloaded items"
},
"libraryFilterLocal": "Local",
"libraryFilterLocal": "ローカル",
"@libraryFilterLocal": {
"description": "Filter chip - show only local library items"
},
"libraryFilterTitle": "Filters",
"libraryFilterTitle": "フィルター",
"@libraryFilterTitle": {
"description": "Filter bottom sheet title"
},
"libraryFilterReset": "Reset",
"libraryFilterReset": "リセット",
"@libraryFilterReset": {
"description": "Reset all filters button"
},
"libraryFilterApply": "Apply",
"libraryFilterApply": "適用",
"@libraryFilterApply": {
"description": "Apply filters button"
},
"libraryFilterSource": "Source",
"libraryFilterSource": "ソース",
"@libraryFilterSource": {
"description": "Filter section - source type"
},
"libraryFilterQuality": "Quality",
"libraryFilterQuality": "品質",
"@libraryFilterQuality": {
"description": "Filter section - audio quality"
},
"libraryFilterQualityHiRes": "Hi-Res (24bit)",
"libraryFilterQualityHiRes": "ハイレゾ (24bit)",
"@libraryFilterQualityHiRes": {
"description": "Filter option - high resolution audio"
},
@@ -2308,7 +2333,7 @@
"@libraryFilterQualityLossy": {
"description": "Filter option - lossy compressed audio"
},
"libraryFilterFormat": "Format",
"libraryFilterFormat": "形式",
"@libraryFilterFormat": {
"description": "Filter section - file format"
},
@@ -2328,7 +2353,7 @@
"@timeJustNow": {
"description": "Relative time - less than a minute ago"
},
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
"timeMinutesAgo": "{count, plural, =1{1 分前} other{{count} 分前}}",
"@timeMinutesAgo": {
"description": "Relative time - minutes ago",
"placeholders": {
@@ -2337,7 +2362,7 @@
}
}
},
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
"timeHoursAgo": "{count, plural, =1{1 時間前} other{{count} 時間前}}",
"@timeHoursAgo": {
"description": "Relative time - hours ago",
"placeholders": {
@@ -2346,7 +2371,7 @@
}
}
},
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
"tutorialWelcomeTitle": "SpotiFLAC へようこそ!",
"@tutorialWelcomeTitle": {
"description": "Tutorial welcome page title"
},
@@ -2374,7 +2399,7 @@
"@tutorialSearchDesc": {
"description": "Tutorial search page description"
},
"tutorialDownloadTitle": "Downloading Music",
"tutorialDownloadTitle": "音楽をダウンロード中",
"@tutorialDownloadTitle": {
"description": "Tutorial download page title"
},
@@ -2382,7 +2407,7 @@
"@tutorialDownloadDesc": {
"description": "Tutorial download page description"
},
"tutorialLibraryTitle": "Your Library",
"tutorialLibraryTitle": "あなたのライブラリ",
"@tutorialLibraryTitle": {
"description": "Tutorial library page title"
},
@@ -2402,7 +2427,7 @@
"@tutorialLibraryTip3": {
"description": "Tutorial library tip 3"
},
"tutorialExtensionsTitle": "Extensions",
"tutorialExtensionsTitle": "拡張",
"@tutorialExtensionsTitle": {
"description": "Tutorial extensions page title"
},
@@ -2446,7 +2471,7 @@
"@tutorialReadyMessage": {
"description": "Tutorial completion message"
},
"libraryForceFullScan": "Force Full Scan",
"libraryForceFullScan": "強制フルスキャン",
"@libraryForceFullScan": {
"description": "Button to force a complete rescan of library"
},
@@ -2475,11 +2500,11 @@
"@cleanupOrphanedDownloadsNone": {
"description": "Snackbar when no orphans found"
},
"cacheTitle": "Storage & Cache",
"cacheTitle": "ストレージとキャッシュ",
"@cacheTitle": {
"description": "Cache management page title"
},
"cacheSummaryTitle": "Cache overview",
"cacheSummaryTitle": "キャッシュの概要",
"@cacheSummaryTitle": {
"description": "Heading for cache summary card"
},
@@ -2496,15 +2521,15 @@
}
}
},
"cacheSectionStorage": "Cached Data",
"cacheSectionStorage": "キャッシュ済みデータ",
"@cacheSectionStorage": {
"description": "Section header for cache entries"
},
"cacheSectionMaintenance": "Maintenance",
"cacheSectionMaintenance": "メンテナンス",
"@cacheSectionMaintenance": {
"description": "Section header for cleanup actions"
},
"cacheAppDirectory": "App cache directory",
"cacheAppDirectory": "アプリキャッシュのディレクトリ",
"@cacheAppDirectory": {
"description": "Cache item title for app cache directory"
},
@@ -2512,7 +2537,7 @@
"@cacheAppDirectoryDesc": {
"description": "Description of what app cache directory contains"
},
"cacheTempDirectory": "Temporary directory",
"cacheTempDirectory": "一時ディレクトリ",
"@cacheTempDirectory": {
"description": "Cache item title for temporary files directory"
},
@@ -2520,7 +2545,7 @@
"@cacheTempDirectoryDesc": {
"description": "Description of what temporary directory contains"
},
"cacheCoverImage": "Cover image cache",
"cacheCoverImage": "カバー画像のキャッシュ",
"@cacheCoverImage": {
"description": "Cache item title for persistent cover images"
},
@@ -2528,7 +2553,7 @@
"@cacheCoverImageDesc": {
"description": "Description of what cover image cache contains"
},
"cacheLibraryCover": "Library cover cache",
"cacheLibraryCover": "ライブラリのカバーキャッシュ",
"@cacheLibraryCover": {
"description": "Cache item title for local library cover art images"
},
@@ -2556,7 +2581,7 @@
"@cacheCleanupUnusedDesc": {
"description": "Description of what cleanup unused data does"
},
"cacheNoData": "No cached data",
"cacheNoData": "キャッシュデータはありません",
"@cacheNoData": {
"description": "Label when cache category has no data"
},
@@ -2581,7 +2606,7 @@
}
}
},
"cacheEntries": "{count} entries",
"cacheEntries": "{count} 個のエントリ",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
@@ -2590,7 +2615,7 @@
}
}
},
"cacheClearSuccess": "Cleared: {target}",
"cacheClearSuccess": "消去済み: {target}",
"@cacheClearSuccess": {
"description": "Snackbar after clearing selected cache",
"placeholders": {
@@ -2599,7 +2624,7 @@
}
}
},
"cacheClearConfirmTitle": "Clear cache?",
"cacheClearConfirmTitle": "キャッシュを消去しますか?",
"@cacheClearConfirmTitle": {
"description": "Dialog title before clearing one cache category"
},
@@ -2612,7 +2637,7 @@
}
}
},
"cacheClearAllConfirmTitle": "Clear all cache?",
"cacheClearAllConfirmTitle": "すべてのキャッシュを消去しますか?",
"@cacheClearAllConfirmTitle": {
"description": "Dialog title before clearing all caches"
},
@@ -2620,11 +2645,11 @@
"@cacheClearAllConfirmMessage": {
"description": "Dialog message before clearing all caches"
},
"cacheClearAll": "Clear all cache",
"cacheClearAll": "すべてのキャッシュを消去",
"@cacheClearAll": {
"description": "Button label to clear all caches"
},
"cacheCleanupUnused": "Cleanup unused data",
"cacheCleanupUnused": "未使用のデータを削除",
"@cacheCleanupUnused": {
"description": "Action title for cleaning unused entries"
},
@@ -2644,11 +2669,11 @@
}
}
},
"cacheRefreshStats": "Refresh stats",
"cacheRefreshStats": "状態を更新",
"@cacheRefreshStats": {
"description": "Button label to refresh cache statistics"
},
"trackSaveCoverArt": "Save Cover Art",
"trackSaveCoverArt": "カバー画像を保存",
"@trackSaveCoverArt": {
"description": "Menu action - save album cover art as file"
},
@@ -2656,7 +2681,7 @@
"@trackSaveCoverArtSubtitle": {
"description": "Subtitle for save cover art action"
},
"trackSaveLyrics": "Save Lyrics (.lrc)",
"trackSaveLyrics": "歌詞を保存 (.lrc)",
"@trackSaveLyrics": {
"description": "Menu action - save lyrics as .lrc file"
},
@@ -2676,7 +2701,7 @@
"@trackReEnrichOnlineSubtitle": {
"description": "Subtitle for re-enrich metadata action for local items"
},
"trackEditMetadata": "Edit Metadata",
"trackEditMetadata": "メタデータを編集",
"@trackEditMetadata": {
"description": "Menu action - edit embedded metadata"
},
@@ -2718,7 +2743,7 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"trackSaveFailed": "Failed: {error}",
"trackSaveFailed": "失敗: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
"placeholders": {
@@ -2727,27 +2752,27 @@
}
}
},
"trackConvertFormat": "Convert Format",
"trackConvertFormat": "変換の形式",
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "MP3 または Opus に変換",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
"trackConvertTitle": "Convert Audio",
"trackConvertTitle": "オーディオを変換",
"@trackConvertTitle": {
"description": "Title of convert bottom sheet"
},
"trackConvertTargetFormat": "Target Format",
"trackConvertTargetFormat": "ターゲットの形式",
"@trackConvertTargetFormat": {
"description": "Label for format selection"
},
"trackConvertBitrate": "Bitrate",
"trackConvertBitrate": "ビットレート",
"@trackConvertBitrate": {
"description": "Label for bitrate selection"
},
"trackConvertConfirmTitle": "Confirm Conversion",
"trackConvertConfirmTitle": "変換を確認",
"@trackConvertConfirmTitle": {
"description": "Confirmation dialog title"
},
@@ -2766,7 +2791,7 @@
}
}
},
"trackConvertConverting": "Converting audio...",
"trackConvertConverting": "オーディオを変換中...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
},
@@ -2779,10 +2804,287 @@
}
}
},
"trackConvertFailed": "Conversion failed",
"trackConvertFailed": "変換に失敗しました",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} 個をダウンロード済み",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+520 -218
View File
File diff suppressed because it is too large. Load diff
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+2 -2
View File
@@ -402,7 +402,7 @@
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1005,7 +1005,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
+3 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.",
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Embutido",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extensão",
"@providerExtension": {
@@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
+357 -55
View File
@@ -77,7 +77,7 @@
"@settingsAbout": {
"description": "Settings section - app info"
},
"downloadTitle": "Скачивание",
"downloadTitle": "Скачать",
"@downloadTitle": {
"description": "Download settings page title"
},
@@ -174,11 +174,11 @@
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
},
"optionsEmbedLyrics": "Вставить текст песни",
"optionsEmbedLyrics": "Вписать текст песни",
"@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files"
},
"optionsEmbedLyricsSubtitle": "Вставить синхронизированные тексты в FLAC файлы",
"optionsEmbedLyricsSubtitle": "Вписать синхронизированные тексты во FLAC файлы",
"@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics"
},
@@ -422,7 +422,7 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!",
"aboutBinimumDesc": "Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
@@ -728,7 +728,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -742,7 +742,7 @@
"description": "Dialog title - import CSV playlist"
},
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
"csvImportTracks": "{count} треков из CSV",
"csvImportTracks": "{count} трек(-ов) из CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
@@ -807,7 +807,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Показать расширенные теги",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Включить форматированные теги для отслеживания заполнения и шаблонов дат",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "Без организации",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1261,7 +1269,7 @@
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Вставить в файл",
"lyricsModeEmbed": "Вписать в файл",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
@@ -1281,7 +1289,7 @@
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Вставить и сохранить файл .lrc",
"lyricsModeBothSubtitle": "Вписать и сохранить .lrc файл",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
@@ -1455,7 +1463,7 @@
"@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails"
},
"trackEmbedLyrics": "Вставить текст песни",
"trackEmbedLyrics": "Вписать текст песни",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "Битрейт YouTube Opus",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "Битрейт YouTube MP3",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -1769,7 +1785,7 @@
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
},
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
"downloadUsePrimaryArtistOnly": "Основной исполнитель только для папок",
"@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name"
},
@@ -1777,7 +1793,7 @@
"@downloadUsePrimaryArtistOnlyEnabled": {
"description": "Subtitle when primary artist only is enabled"
},
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
"downloadUsePrimaryArtistOnlyDisabled": "Полная строка исполнителя, используемая для имени папки",
"@downloadUsePrimaryArtistOnlyDisabled": {
"description": "Subtitle when primary artist only is disabled"
},
@@ -1817,7 +1833,7 @@
"@settingsDownloadNetwork": {
"description": "Setting for network type preference"
},
"settingsDownloadNetworkAny": "WiFi и мобильная сеть",
"settingsDownloadNetworkAny": "WiFi и Мобильная сеть",
"@settingsDownloadNetworkAny": {
"description": "Network option - use any connection"
},
@@ -1873,7 +1889,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -1899,7 +1915,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Последнее сканирование: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2304,7 +2329,7 @@
"@libraryFilterQualityCD": {
"description": "Filter option - CD quality audio"
},
"libraryFilterQualityLossy": "С потерями",
"libraryFilterQualityLossy": "Lossy",
"@libraryFilterQualityLossy": {
"description": "Filter option - lossy compressed audio"
},
@@ -2410,7 +2435,7 @@
"@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description"
},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"tutorialExtensionsTip1": "Просмотрите вкладку Магазина, чтобы найти полезные расширения",
"@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1"
},
@@ -2418,7 +2443,7 @@
"@tutorialExtensionsTip2": {
"description": "Tutorial extensions tip 2"
},
"tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features",
"tutorialExtensionsTip3": "Получайте тексты песен, улучшенные метаданные и другие возможности",
"@tutorialExtensionsTip3": {
"description": "Tutorial extensions tip 3"
},
@@ -2426,7 +2451,7 @@
"@tutorialSettingsTitle": {
"description": "Tutorial settings page title"
},
"tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.",
"tutorialSettingsDesc": "Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.",
"@tutorialSettingsDesc": {
"description": "Tutorial settings page description"
},
@@ -2454,11 +2479,11 @@
"@libraryForceFullScanSubtitle": {
"description": "Subtitle for force full scan button"
},
"cleanupOrphanedDownloads": "Cleanup Orphaned Downloads",
"cleanupOrphanedDownloads": "Очистка отложенных скачиваний",
"@cleanupOrphanedDownloads": {
"description": "Button to remove history entries for deleted files"
},
"cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist",
"cleanupOrphanedDownloadsSubtitle": "Удалить историю записи для файлов, которых больше не существует",
"@cleanupOrphanedDownloadsSubtitle": {
"description": "Subtitle for orphaned cleanup button"
},
@@ -2471,7 +2496,7 @@
}
}
},
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
"cleanupOrphanedDownloadsNone": "Записей без описания не найдено",
"@cleanupOrphanedDownloadsNone": {
"description": "Snackbar when no orphans found"
},
@@ -2483,11 +2508,11 @@
"@cacheSummaryTitle": {
"description": "Heading for cache summary card"
},
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
"cacheSummarySubtitle": "Очистка кэша не приведет к удалению загруженных музыкальных файлов.",
"@cacheSummarySubtitle": {
"description": "Helper text for cache summary card"
},
"cacheEstimatedTotal": "Estimated cache usage: {size}",
"cacheEstimatedTotal": "Приблизительное использование кэша: {size}",
"@cacheEstimatedTotal": {
"description": "Total cache size shown in summary",
"placeholders": {
@@ -2508,47 +2533,47 @@
"@cacheAppDirectory": {
"description": "Cache item title for app cache directory"
},
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
"cacheAppDirectoryDesc": "HTTP-ответы, данные WebView и другие временные данные приложения.",
"@cacheAppDirectoryDesc": {
"description": "Description of what app cache directory contains"
},
"cacheTempDirectory": "Temporary directory",
"cacheTempDirectory": "Временная директория",
"@cacheTempDirectory": {
"description": "Cache item title for temporary files directory"
},
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
"cacheTempDirectoryDesc": "Временные файлы из загрузок и аудио конвертации.",
"@cacheTempDirectoryDesc": {
"description": "Description of what temporary directory contains"
},
"cacheCoverImage": "Cover image cache",
"cacheCoverImage": "Кэш обложек",
"@cacheCoverImage": {
"description": "Cache item title for persistent cover images"
},
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
"cacheCoverImageDesc": "Скачанный альбом и трек обложки. Будет заново скачан после просмотра.",
"@cacheCoverImageDesc": {
"description": "Description of what cover image cache contains"
},
"cacheLibraryCover": "Library cover cache",
"cacheLibraryCover": "Кэш обложек библиотеки",
"@cacheLibraryCover": {
"description": "Cache item title for local library cover art images"
},
"cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.",
"cacheLibraryCoverDesc": "Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.",
"@cacheLibraryCoverDesc": {
"description": "Description of what library cover cache contains"
},
"cacheExploreFeed": "Explore feed cache",
"cacheExploreFeed": "Просмотреть кэш ленты",
"@cacheExploreFeed": {
"description": "Cache item title for explore home feed cache"
},
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
"cacheExploreFeedDesc": "Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.",
"@cacheExploreFeedDesc": {
"description": "Description of what explore feed cache contains"
},
"cacheTrackLookup": "Track lookup cache",
"cacheTrackLookup": "Отслеживать кэш поиска",
"@cacheTrackLookup": {
"description": "Cache item title for track ID lookup cache"
},
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
"cacheTrackLookupDesc": "Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.",
"@cacheTrackLookupDesc": {
"description": "Description of what track lookup cache contains"
},
@@ -2581,7 +2606,7 @@
}
}
},
"cacheEntries": "{count} entries",
"cacheEntries": "{count} записей",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
@@ -2603,7 +2628,7 @@
"@cacheClearConfirmTitle": {
"description": "Dialog title before clearing one cache category"
},
"cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.",
"cacheClearConfirmMessage": "Это очистит кэш для {target}. Загруженные музыкальные файлы не будут удалены.",
"@cacheClearConfirmMessage": {
"description": "Dialog message before clearing selected cache",
"placeholders": {
@@ -2632,7 +2657,7 @@
"@cacheCleanupUnusedSubtitle": {
"description": "Subtitle for cleanup unused data action"
},
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
"cacheCleanupResult": "Очистка завершена: {downloadCount} потерянных загрузок, {libraryCount} отсутствующих записей в библиотеке",
"@cacheCleanupResult": {
"description": "Snackbar after unused data cleanup",
"placeholders": {
@@ -2664,15 +2689,15 @@
"@trackSaveLyricsSubtitle": {
"description": "Subtitle for save lyrics action"
},
"trackSaveLyricsProgress": "Saving lyrics...",
"trackSaveLyricsProgress": "Сохранение текста...",
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich",
"trackReEnrich": "Обновить",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
"trackReEnrichOnlineSubtitle": "Поиск в сети метаданных и встраивание в файл",
"@trackReEnrichOnlineSubtitle": {
"description": "Subtitle for re-enrich metadata action for local items"
},
@@ -2702,7 +2727,7 @@
}
}
},
"trackReEnrichProgress": "Re-enriching metadata...",
"trackReEnrichProgress": "Обновление метаданных...",
"@trackReEnrichProgress": {
"description": "Snackbar while re-enriching metadata"
},
@@ -2710,7 +2735,7 @@
"@trackReEnrichSearching": {
"description": "Snackbar while searching metadata from internet for local items"
},
"trackReEnrichSuccess": "Metadata re-enriched successfully",
"trackReEnrichSuccess": "Метаданные успешно обновлены",
"@trackReEnrichSuccess": {
"description": "Snackbar after successful re-enrichment"
},
@@ -2727,31 +2752,31 @@
}
}
},
"trackConvertFormat": "Convert Format",
"trackConvertFormat": "Переконвертировать формат",
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Конвертировать в MP3 или Opus",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
"trackConvertTitle": "Convert Audio",
"trackConvertTitle": "Конвертировать аудио",
"@trackConvertTitle": {
"description": "Title of convert bottom sheet"
},
"trackConvertTargetFormat": "Target Format",
"trackConvertTargetFormat": "Целевой формат",
"@trackConvertTargetFormat": {
"description": "Label for format selection"
},
"trackConvertBitrate": "Bitrate",
"trackConvertBitrate": "Битрейт",
"@trackConvertBitrate": {
"description": "Label for bitrate selection"
},
"trackConvertConfirmTitle": "Confirm Conversion",
"trackConvertConfirmTitle": "Подтвердить конвертацию",
"@trackConvertConfirmTitle": {
"description": "Confirmation dialog title"
},
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
"trackConvertConfirmMessage": "Конвертировать из {sourceFormat} в {targetFormat} {bitrate}?\n\nОригинальный файл будет удален после конвертации.",
"@trackConvertConfirmMessage": {
"description": "Confirmation dialog message",
"placeholders": {
@@ -2766,11 +2791,11 @@
}
}
},
"trackConvertConverting": "Converting audio...",
"trackConvertConverting": "Конвертация аудио...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
},
"trackConvertSuccess": "Converted to {format} successfully",
"trackConvertSuccess": "Успешно конвертировано в {format}",
"@trackConvertSuccess": {
"description": "Snackbar after successful conversion",
"placeholders": {
@@ -2779,10 +2804,287 @@
}
}
},
"trackConvertFailed": "Conversion failed",
"trackConvertFailed": "Ошибка конвертации",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Создать",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "Мои папки",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Список желаемого",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Любимые",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Плейлисты",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Плейлист",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Добавить в плейлист",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Создать плейлист",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "Плейлисты отсутствуют",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Создайте плейлист, чтобы начать классифицировать треки",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Добавлено в \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Уже в \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Плейлист создан",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Название плейлиста",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Имя плейлиста обязательно",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Переименовать плейлист",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Удалить плейлист",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Удалить \"{playlistName}\" и все треки внутри него?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Плейлист удалён",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Плейлист переименован",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Список желаний пуст",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Папка Любимые пуста",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Плейлист пуст",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Удерживайте + на любом треке, чтобы добавить его сюда",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Удалить из плейлиста",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Убрать из папки",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" удалён",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" добавлен в Любимые",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" удалено из Любимых",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" добавлен в список желаний",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" удалён из списка желаний",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Добавить в Любимое",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Исключить из Любимых",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Добавить в список желаний",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Удалить из списка желаний",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Изменить обложку",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Удалить обложку",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "Не выбраны конвертируемые треки",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Пакетная конвертация",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Конвертация {current} из {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Конвертировано {success} треков {total} в {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} скачано",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2792,7 +3094,7 @@
}
}
},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Для папок исполнителей используется исполнитель альбома, если он указан",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
"description": "Subtitle when Album Artist is used for folder naming"
},
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+3 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Spotify şarkılarını Tidal, Qobuz ve Amazon Music'den yüksek kalitede indir.",
"aboutAppDescription": "Spotify şarkılarını Tidal ve Qobuz'den yüksek kalitede indir.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Dahili",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Eklenti",
"@providerExtension": {
@@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Tidal, Qobuz veya Deezer'den FLAC kalitesinde ses alın",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
+2 -2
View File
@@ -402,7 +402,7 @@
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1005,7 +1005,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+4
View File
@@ -34,6 +34,7 @@ class DownloadItem {
final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
final String? playlistName; // Playlist context for folder organization
const DownloadItem({
required this.id,
@@ -48,6 +49,7 @@ class DownloadItem {
this.errorType,
required this.createdAt,
this.qualityOverride,
this.playlistName,
});
DownloadItem copyWith({
@@ -63,6 +65,7 @@ class DownloadItem {
DownloadErrorType? errorType,
DateTime? createdAt,
String? qualityOverride,
String? playlistName,
}) {
return DownloadItem(
id: id ?? this.id,
@@ -77,6 +80,7 @@ class DownloadItem {
errorType: errorType ?? this.errorType,
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
playlistName: playlistName ?? this.playlistName,
);
}
+2
View File
@@ -21,6 +21,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
playlistName: json['playlistName'] as String?,
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -37,6 +38,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
'playlistName': instance.playlistName,
};
const _$DownloadStatusEnumMap = {
+7 -18
View File
@@ -55,17 +55,16 @@ class AppSettings {
final String
songLinkRegion; // SongLink userCountry region code used for platform lookup
// Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final String
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
// Tutorial/Onboarding
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
// Lyrics Provider Settings
final List<String>
lyricsProviders; // Ordered list of enabled lyrics provider IDs
final bool
@@ -77,7 +76,6 @@ class AppSettings {
final String
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
// Version upgrade tracking
final String
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
@@ -106,7 +104,7 @@ class AppSettings {
this.askQualityBeforeDownload = true,
this.spotifyClientId = '',
this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = true,
this.useCustomSpotifyCredentials = false,
this.metadataSource = 'deezer',
this.enableLogging = false,
this.useExtensionProviders = true,
@@ -124,13 +122,11 @@ class AppSettings {
this.downloadNetworkMode = 'any',
this.networkCompatibilityMode = false,
this.songLinkRegion = 'US',
// Local Library defaults
this.localLibraryEnabled = false,
this.localLibraryPath = '',
this.localLibraryBookmark = '',
this.localLibraryShowDuplicates = true,
// Tutorial default
this.hasCompletedTutorial = false,
// Lyrics providers default order
this.lyricsProviders = const [
'lrclib',
'spotify_api',
@@ -143,7 +139,6 @@ class AppSettings {
this.lyricsIncludeRomanizationNetease = false,
this.lyricsMultiPersonWordByWord = false,
this.musixmatchLanguage = '',
// Version upgrade tracking
this.lastSeenVersion = '',
});
@@ -154,7 +149,7 @@ class AppSettings {
String? downloadDirectory,
String? storageMode,
String? downloadTreeUri,
bool? autoFallback,
bool? autoFallback,
bool? embedMetadata,
bool? embedLyrics,
bool? maxQualityCover,
@@ -191,19 +186,16 @@ class AppSettings {
String? downloadNetworkMode,
bool? networkCompatibilityMode,
String? songLinkRegion,
// Local Library
bool? localLibraryEnabled,
String? localLibraryPath,
String? localLibraryBookmark,
bool? localLibraryShowDuplicates,
// Tutorial
bool? hasCompletedTutorial,
// Lyrics providers
List<String>? lyricsProviders,
bool? lyricsIncludeTranslationNetease,
bool? lyricsIncludeRomanizationNetease,
bool? lyricsMultiPersonWordByWord,
String? musixmatchLanguage,
// Version upgrade tracking
String? lastSeenVersion,
}) {
return AppSettings(
@@ -259,14 +251,12 @@ class AppSettings {
networkCompatibilityMode:
networkCompatibilityMode ?? this.networkCompatibilityMode,
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
// Tutorial
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
// Lyrics providers
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
lyricsIncludeTranslationNetease:
lyricsIncludeTranslationNetease ??
@@ -277,7 +267,6 @@ class AppSettings {
lyricsMultiPersonWordByWord:
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
// Version upgrade tracking
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
);
}
+3 -1
View File
@@ -33,7 +33,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true,
json['useCustomSpotifyCredentials'] as bool? ?? false,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
@@ -55,6 +55,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
@@ -128,6 +129,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'songLinkRegion': instance.songLinkRegion,
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryBookmark': instance.localLibraryBookmark,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
'hasCompletedTutorial': instance.hasCompletedTutorial,
'lyricsProviders': instance.lyricsProviders,
+13 -1
View File
@@ -21,6 +21,7 @@ class Track {
final ServiceAvailability? availability;
final String? source;
final String? albumType;
final int? totalTracks;
final String? itemType;
const Track({
@@ -41,10 +42,21 @@ class Track {
this.availability,
this.source,
this.albumType,
this.totalTracks,
this.itemType,
});
bool get isSingle => albumType == 'single' || albumType == 'ep';
bool get isSingle {
switch (albumType?.toLowerCase()) {
case 'single':
return true;
case 'ep':
final count = totalTracks;
return count == null || count <= 1;
default:
return false;
}
}
bool get isAlbumItem => itemType == 'album';
+2
View File
@@ -28,6 +28,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
),
source: json['source'] as String?,
albumType: json['albumType'] as String?,
totalTracks: (json['totalTracks'] as num?)?.toInt(),
itemType: json['itemType'] as String?,
);
@@ -49,6 +50,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'availability': instance.availability,
'source': instance.source,
'albumType': instance.albumType,
'totalTracks': instance.totalTracks,
'itemType': instance.itemType,
};
+277 -31
View File
@@ -251,9 +251,11 @@ class DownloadHistoryState {
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
static const int _audioMetadataBackfillMaxPerLaunch = 24;
final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
bool _isSafRepairInProgress = false;
bool _isAudioMetadataBackfillInProgress = false;
@override
DownloadHistoryState build() {
@@ -298,9 +300,19 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
maxItems: _safRepairMaxPerLaunch,
);
await cleanupOrphanedDownloads();
await _backfillAudioMetadata(
state.items,
maxItems: _audioMetadataBackfillMaxPerLaunch,
);
});
} else {
Future.microtask(() => cleanupOrphanedDownloads());
Future.microtask(() async {
await cleanupOrphanedDownloads();
await _backfillAudioMetadata(
state.items,
maxItems: _audioMetadataBackfillMaxPerLaunch,
);
});
}
} catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack);
@@ -429,6 +441,157 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
}
int? _readPositiveInt(dynamic value) {
if (value == null) return null;
if (value is num) {
final asInt = value.toInt();
return asInt > 0 ? asInt : null;
}
final parsed = int.tryParse(value.toString());
if (parsed == null || parsed <= 0) return null;
return parsed;
}
bool _supportsAudioMetadataProbe(String filePath) {
final trimmed = filePath.trim().toLowerCase();
if (trimmed.isEmpty) return false;
if (trimmed.startsWith('content://')) return true;
return trimmed.endsWith('.flac') ||
trimmed.endsWith('.m4a') ||
trimmed.endsWith('.aac') ||
trimmed.endsWith('.mp3') ||
trimmed.endsWith('.opus') ||
trimmed.endsWith('.ogg');
}
bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) {
if (!_supportsAudioMetadataProbe(item.filePath)) {
return false;
}
final trimmedPath = item.filePath.trim().toLowerCase();
final hasResolvedSpecs =
item.bitDepth != null &&
item.bitDepth! > 0 &&
item.sampleRate != null &&
item.sampleRate! > 0;
final needsLosslessSpecProbe =
!hasResolvedSpecs &&
(trimmedPath.endsWith('.flac') ||
trimmedPath.endsWith('.m4a') ||
trimmedPath.endsWith('.aac') ||
trimmedPath.startsWith('content://'));
if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) {
return false;
}
return needsLosslessSpecProbe ||
isPlaceholderQualityLabel(item.quality) ||
normalizeOptionalString(item.quality) == null;
}
Future<Map<String, dynamic>?> _probeAudioMetadata(
String filePath, {
String? fallbackQuality,
}) async {
if (!_supportsAudioMetadataProbe(filePath)) {
return null;
}
try {
final result = await PlatformBridge.readFileMetadata(filePath);
if (result['error'] != null) {
return null;
}
final bitDepth = _readPositiveInt(result['bit_depth']);
final sampleRate = _readPositiveInt(result['sample_rate']);
final quality = buildDisplayAudioQuality(
bitDepth: bitDepth,
sampleRate: sampleRate,
storedQuality: fallbackQuality,
);
if (quality == null && bitDepth == null && sampleRate == null) {
return null;
}
return {
'quality': quality,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
};
} catch (e) {
_historyLog.d('Audio metadata probe failed for $filePath: $e');
return null;
}
}
Future<void> _backfillAudioMetadata(
List<DownloadHistoryItem> items, {
required int maxItems,
}) async {
if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
return;
}
_isAudioMetadataBackfillInProgress = true;
try {
var refreshedCount = 0;
for (final item in items) {
if (refreshedCount >= maxItems) {
break;
}
if (!_shouldBackfillAudioMetadata(item)) {
continue;
}
final probed = await _probeAudioMetadata(
item.filePath,
fallbackQuality: item.quality,
);
if (probed == null) {
continue;
}
final resolvedQuality = normalizeOptionalString(
probed['quality'] as String?,
);
final resolvedBitDepth = probed['bitDepth'] as int?;
final resolvedSampleRate = probed['sampleRate'] as int?;
final qualityChanged =
resolvedQuality != null && resolvedQuality != item.quality;
final bitDepthChanged =
resolvedBitDepth != null && resolvedBitDepth != item.bitDepth;
final sampleRateChanged =
resolvedSampleRate != null && resolvedSampleRate != item.sampleRate;
if (!qualityChanged && !bitDepthChanged && !sampleRateChanged) {
continue;
}
await updateAudioMetadataForItem(
id: item.id,
quality: resolvedQuality,
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
);
refreshedCount++;
}
if (refreshedCount > 0) {
_historyLog.i(
'Audio metadata backfill refreshed $refreshedCount items',
);
}
} finally {
_isAudioMetadataBackfillInProgress = false;
}
}
Future<void> reloadFromStorage() async {
await _loadFromDatabase();
}
@@ -509,6 +672,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return DownloadHistoryItem.fromJson(json);
}
Future<void> updateAudioMetadataForItem({
required String id,
String? quality,
int? bitDepth,
int? sampleRate,
}) async {
final index = state.items.indexWhere((item) => item.id == id);
if (index < 0) return;
final current = state.items[index];
final updated = current.copyWith(
quality: quality,
bitDepth: bitDepth,
sampleRate: sampleRate,
);
if (updated.quality == current.quality &&
updated.bitDepth == current.bitDepth &&
updated.sampleRate == current.sampleRate) {
return;
}
final updatedItems = [...state.items];
updatedItems[index] = updated;
state = state.copyWith(items: updatedItems);
await _db.updateAudioMetadata(
id,
newQuality: quality,
newBitDepth: bitDepth,
newSampleRate: sampleRate,
);
}
Future<void> updateMetadataForItem({
required String id,
required String trackName,
@@ -592,10 +788,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return 0;
}
// Delete from database
final deletedCount = await _db.deleteByIds(orphanedIds);
// Update in-memory state
final orphanedSet = orphanedIds.toSet();
state = state.copyWith(
items: state.items
@@ -1379,6 +1573,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
String? playlistName,
}) async {
String baseDir = state.outputDir;
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
@@ -1453,6 +1648,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String subPath = '';
switch (folderOrganization) {
case 'playlist':
if (playlistName != null && playlistName.isNotEmpty) {
subPath = _sanitizeFolderName(playlistName);
}
break;
case 'artist':
final artistName = _sanitizeFolderName(folderArtist);
subPath = artistName;
@@ -1531,6 +1731,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
String? playlistName,
}) async {
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders
@@ -1582,6 +1783,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
switch (folderOrganization) {
case 'playlist':
if (playlistName != null && playlistName.isNotEmpty) {
return _sanitizeFolderName(playlistName);
}
return '';
case 'artist':
return _sanitizeFolderName(folderArtist);
case 'album':
@@ -1596,18 +1802,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
String _determineOutputExt(String quality, String service) {
// YouTube provider - lossy only (Opus or MP3)
if (service.toLowerCase() == 'youtube') {
if (quality.toLowerCase().contains('mp3')) {
return '.mp3';
}
return '.opus';
}
// Amazon stream is delivered as MP4/M4A container (may contain FLAC audio),
// so SAF should keep .m4a before decrypt/convert pipeline.
if (service.toLowerCase() == 'amazon') {
return '.m4a';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a';
}
@@ -1712,7 +1912,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
String addToQueue(Track track, String service, {String? qualityOverride}) {
String addToQueue(Track track, String service, {String? qualityOverride, String? playlistName}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -1724,6 +1924,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
);
state = state.copyWith(items: [...state.items, item]);
@@ -1740,6 +1941,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
List<Track> tracks,
String service, {
String? qualityOverride,
String? playlistName,
}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -1754,6 +1956,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
);
}).toList();
@@ -2159,6 +2362,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
deezerId: baseTrack.deezerId,
availability: baseTrack.availability,
albumType: baseTrack.albumType,
totalTracks: baseTrack.totalTracks,
source: baseTrack.source,
);
}
@@ -2903,7 +3107,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
failedCount: _failedInSession,
);
// Auto-export failed downloads if enabled
final settings = ref.read(settingsProvider);
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
final exportPath = await exportFailedDownloads();
@@ -3072,6 +3275,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumType:
(data['album_type'] as String?) ??
trackToDownload.albumType,
totalTracks:
data['total_tracks'] as int? ?? trackToDownload.totalTracks,
source: trackToDownload.source,
);
_log.d(
@@ -3130,6 +3335,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
)
: '';
String? appOutputDir;
@@ -3144,6 +3350,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
);
var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode;
@@ -3206,7 +3413,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) {
try {
// Extract clean Spotify ID (remove spotify: prefix if present)
String spotifyId = trackToDownload.id;
if (spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.split(':').last;
@@ -3285,6 +3491,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
deezerId: deezerTrackId,
availability: trackToDownload.availability,
albumType: trackToDownload.albumType,
totalTracks: trackToDownload.totalTracks,
source: trackToDownload.source,
);
_log.d(
@@ -3506,11 +3713,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final decryptionKey =
(result['decryption_key'] as String?)?.trim() ?? '';
if (!wasExisting &&
decryptionKey.isNotEmpty &&
filePath != null &&
actualService == 'amazon') {
_log.i('Amazon encrypted stream detected, decrypting via FFmpeg...');
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
if (effectiveSafMode && isContentUri(filePath)) {
@@ -3539,7 +3743,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to decrypt Amazon stream',
error: 'Failed to decrypt encrypted stream',
errorType: DownloadErrorType.unknown,
);
return;
@@ -3564,7 +3768,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (newUri == null) {
_log.e('Failed to write decrypted Amazon stream back to SAF');
_log.e('Failed to write decrypted stream back to SAF');
updateItemStatus(
item.id,
DownloadStatus.failed,
@@ -3579,7 +3783,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
filePath = newUri;
finalSafFileName = newFileName;
_log.i('Amazon SAF decryption completed');
_log.i('SAF decryption completed');
} finally {
try {
await File(tempPath).delete();
@@ -3601,7 +3805,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to decrypt Amazon stream',
error: 'Failed to decrypt encrypted stream',
errorType: DownloadErrorType.unknown,
);
try {
@@ -3610,7 +3814,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
filePath = decryptedPath;
_log.i('Amazon local decryption completed');
_log.i('Local decryption completed');
}
}
@@ -3832,7 +4036,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} else {
// Local file path flow (original)
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
@@ -4049,10 +4252,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!effectiveSafMode &&
isFlacFile &&
!wasExisting &&
actualService == 'amazon' &&
decryptionKey.isNotEmpty) {
_log.d(
'Local FLAC after Amazon decrypt detected, embedding metadata and cover...',
'Local FLAC after decrypt detected, embedding metadata and cover...',
);
try {
updateItemStatus(
@@ -4112,7 +4314,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isContentUriPath = isContentUri(filePath);
if (isContentUriPath && effectiveSafMode) {
// SAF mode: copy to temp, embed, write back
final tempPath = await _copySafToTemp(filePath);
if (tempPath != null) {
try {
@@ -4133,7 +4334,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
copyright: backendCopyright,
);
}
// Write back to SAF
final ext = isMp3File ? '.mp3' : '.opus';
final newFileName = '${safBaseName ?? 'track'}$ext';
final newUri = await _writeTempToSaf(
@@ -4162,7 +4362,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} else {
// Non-SAF mode: embed directly
try {
if (isMp3File) {
await _embedMetadataToMp3(
@@ -4347,6 +4546,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
normalizeOptionalString(copyright) ??
normalizeOptionalString(existingInHistory?.copyright);
int? finalBitDepth = backendBitDepth;
int? finalSampleRate = backendSampleRate;
final lowerFilePath = filePath.toLowerCase();
final canProbeFinalMetadata =
filePath.startsWith('content://') ||
lowerFilePath.endsWith('.flac') ||
lowerFilePath.endsWith('.m4a') ||
lowerFilePath.endsWith('.aac') ||
lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') ||
lowerFilePath.endsWith('.ogg');
if (canProbeFinalMetadata) {
try {
final metadata = await PlatformBridge.readFileMetadata(filePath);
if (metadata['error'] == null) {
final probedBitDepth = metadata['bit_depth'] is num
? (metadata['bit_depth'] as num).toInt()
: int.tryParse(metadata['bit_depth']?.toString() ?? '');
final probedSampleRate = metadata['sample_rate'] is num
? (metadata['sample_rate'] as num).toInt()
: int.tryParse(metadata['sample_rate']?.toString() ?? '');
if (probedBitDepth != null && probedBitDepth > 0) {
finalBitDepth = probedBitDepth;
}
if (probedSampleRate != null && probedSampleRate > 0) {
finalSampleRate = probedSampleRate;
}
final resolvedQuality = buildDisplayAudioQuality(
bitDepth: finalBitDepth,
sampleRate: finalSampleRate,
storedQuality: actualQuality,
);
if (resolvedQuality != null) {
actualQuality = resolvedQuality;
}
}
} catch (e) {
_log.d('Final audio metadata probe failed for $filePath: $e');
}
}
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final historyAlbumArtist =
@@ -4354,9 +4597,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? resolvedAlbumArtist
: null;
final isMp3 = filePath.endsWith('.mp3');
final historyBitDepth = isMp3 ? null : backendBitDepth;
final historySampleRate = isMp3 ? null : backendSampleRate;
final isLossyOutput =
lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') ||
lowerFilePath.endsWith('.ogg');
final historyBitDepth = isLossyOutput ? null : finalBitDepth;
final historySampleRate = isLossyOutput ? null : finalSampleRate;
ref
.read(downloadHistoryProvider.notifier)
+38 -19
View File
@@ -757,7 +757,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> loadProviderPriority() async {
try {
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_providerPriorityKey);
@@ -768,10 +767,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
priority = _sanitizeDownloadProviderPriority(priority);
_log.d('Loaded provider priority from prefs: $priority');
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
// Sync to Go backend
await PlatformBridge.setProviderPriority(priority);
} else {
// Fallback to Go backend default
priority = await PlatformBridge.getProviderPriority();
priority = _sanitizeDownloadProviderPriority(priority);
await PlatformBridge.setProviderPriority(priority);
@@ -787,11 +784,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setProviderPriority(List<String> priority) async {
try {
final sanitized = _sanitizeDownloadProviderPriority(priority);
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
// Sync to Go backend
await PlatformBridge.setProviderPriority(sanitized);
state = state.copyWith(providerPriority: sanitized);
_log.d('Saved provider priority: $sanitized');
@@ -811,7 +806,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) {
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
if (!result.contains(provider)) {
result.add(provider);
}
@@ -822,20 +817,25 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> loadMetadataProviderPriority() async {
try {
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_metadataProviderPriorityKey);
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
priority = _sanitizeMetadataProviderPriority(
saved.map((e) => e as String).toList(),
);
_log.d('Loaded metadata provider priority from prefs: $priority');
// Sync to Go backend
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
// Fallback to Go backend default
priority = await PlatformBridge.getMetadataProviderPriority();
priority = _sanitizeMetadataProviderPriority(
await PlatformBridge.getMetadataProviderPriority(),
);
_log.d('Using default metadata provider priority: $priority');
}
@@ -847,14 +847,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setMetadataProviderPriority(List<String> priority) async {
try {
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
final sanitized = _sanitizeMetadataProviderPriority(priority);
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(sanitized),
);
// Sync to Go backend
await PlatformBridge.setMetadataProviderPriority(priority);
state = state.copyWith(metadataProviderPriority: priority);
_log.d('Saved metadata provider priority: $priority');
await PlatformBridge.setMetadataProviderPriority(sanitized);
state = state.copyWith(metadataProviderPriority: sanitized);
_log.d('Saved metadata provider priority: $sanitized');
} catch (e) {
_log.e('Failed to set metadata provider priority: $e');
state = state.copyWith(error: e.toString());
@@ -880,7 +882,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'amazon', 'deezer'];
final providers = ['tidal', 'qobuz', 'deezer'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id);
@@ -890,7 +892,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllMetadataProviders() {
final providers = ['deezer', 'spotify'];
final providers = ['deezer'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
@@ -899,6 +901,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return providers;
}
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
final allowed = getAllMetadataProviders().toSet();
final result = <String>[];
for (final provider in input) {
if (allowed.contains(provider) && !result.contains(provider)) {
result.add(provider);
}
}
if (!result.contains('deezer')) {
result.insert(0, 'deezer');
}
return result;
}
List<Extension> get searchProviders {
return state.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
+53 -75
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
final _log = AppLogger('LocalLibrary');
@@ -193,74 +194,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase();
}
Set<String> _buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
final keys = <String>{};
void addNormalized(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
keys.add(trimmed);
keys.add(trimmed.toLowerCase());
if (trimmed.contains('\\')) {
final slash = trimmed.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
if (trimmed.contains('%')) {
try {
final decoded = Uri.decodeFull(trimmed);
keys.add(decoded);
keys.add(decoded.toLowerCase());
} catch (_) {}
}
Uri? parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {}
if (parsed != null && parsed.hasScheme) {
final noQueryOrFragment = parsed.replace(query: null, fragment: null);
keys.add(noQueryOrFragment.toString());
keys.add(noQueryOrFragment.toString().toLowerCase());
if (parsed.scheme == 'file') {
try {
final fileOnly = parsed.toFilePath();
if (fileOnly.isNotEmpty) {
keys.add(fileOnly);
keys.add(fileOnly.toLowerCase());
if (fileOnly.contains('\\')) {
final slash = fileOnly.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
}
} catch (_) {}
}
} else if (trimmed.startsWith('/')) {
try {
final asFileUri = Uri.file(trimmed).toString();
keys.add(asFileUri);
keys.add(asFileUri.toLowerCase());
} catch (_) {}
}
}
addNormalized(cleaned);
return keys;
}
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false;
}
final candidateKeys = _buildPathMatchKeys(filePath);
final candidateKeys = buildPathMatchKeys(filePath);
for (final key in candidateKeys) {
if (downloadedPathKeys.contains(key)) {
return true;
@@ -272,6 +210,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
String? iosBookmark,
}) async {
if (state.isScanning) {
_log.w('Scan already in progress');
@@ -316,8 +255,28 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_startProgressPolling();
// On iOS, start accessing the security-scoped bookmark so the Go backend
// can read files outside the app sandbox.
String? resolvedPath;
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
resolvedPath = await PlatformBridge.startAccessingIosBookmark(
iosBookmark,
);
if (resolvedPath != null) {
didStartSecurityAccess = true;
_log.i('Started iOS security-scoped access: $resolvedPath');
} else {
_log.w(
'Failed to start iOS security-scoped access, '
'falling back to original path',
);
}
}
final effectiveFolderPath = resolvedPath ?? folderPath;
try {
final isSaf = folderPath.startsWith('content://');
final isSaf = effectiveFolderPath.startsWith('content://');
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
@@ -334,7 +293,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
};
final downloadedPathKeys = <String>{};
for (final path in allHistoryPaths) {
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
downloadedPathKeys.addAll(buildPathMatchKeys(path));
}
_log.i(
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
@@ -344,8 +303,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (forceFullScan) {
// Full scan path - ignores existing data
final results = isSaf
? await PlatformBridge.scanSafTree(folderPath)
: await PlatformBridge.scanLibraryFolder(folderPath);
? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
@@ -424,12 +383,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final Map<String, dynamic> result;
if (isSaf) {
result = await PlatformBridge.scanSafTreeIncremental(
folderPath,
effectiveFolderPath,
existingFiles,
);
} else {
result = await PlatformBridge.scanLibraryFolderIncremental(
folderPath,
effectiveFolderPath,
existingFiles,
);
}
@@ -553,6 +512,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(isScanning: false, scanWasCancelled: false);
await _showScanFailedNotification(e.toString());
} finally {
if (didStartSecurityAccess) {
await PlatformBridge.stopAccessingIosBookmark();
_log.i('Stopped iOS security-scoped access');
}
_stopProgressPolling();
}
}
@@ -807,12 +770,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return decoded;
}
Future<int> cleanupMissingFiles() async {
final removed = await _db.cleanupMissingFiles();
if (removed > 0) {
await reloadFromStorage();
Future<int> cleanupMissingFiles({String? iosBookmark}) async {
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
final resolved = await PlatformBridge.startAccessingIosBookmark(
iosBookmark,
);
if (resolved != null) {
didStartSecurityAccess = true;
}
}
try {
final removed = await _db.cleanupMissingFiles();
if (removed > 0) {
await reloadFromStorage();
}
return removed;
} finally {
if (didStartSecurityAccess) {
await PlatformBridge.stopAccessingIosBookmark();
}
}
return removed;
}
Future<void> clearLibrary() async {
+12
View File
@@ -24,6 +24,9 @@ class PlaybackController extends Notifier<PlaybackState> {
String coverUrl = '',
Track? track,
}) async {
if (isCueVirtualPath(path)) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
_log.d('Opening external player for "$title" by $artist: $path');
await openFile(path);
}
@@ -32,11 +35,16 @@ class PlaybackController extends Notifier<PlaybackState> {
if (tracks.isEmpty) return;
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
var skippedCueVirtualTrack = false;
for (final track in orderedTracks) {
final resolvedPath = await _resolveTrackPath(track);
if (resolvedPath == null) {
continue;
}
if (isCueVirtualPath(resolvedPath)) {
skippedCueVirtualTrack = true;
continue;
}
_log.d(
'Opening first available external track for list playback: '
@@ -46,6 +54,10 @@ class PlaybackController extends Notifier<PlaybackState> {
return;
}
if (skippedCueVirtualTrack) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
throw Exception(
'No local audio file is available to open. Download the track first.',
);
+43 -78
View File
@@ -9,7 +9,7 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 4;
const _currentMigrationVersion = 5;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
@@ -41,9 +41,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _normalizeSongLinkRegionIfNeeded();
}
await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials();
await _retireBuiltInSpotifyProvider();
LogBuffer.loggingEnabled = state.enableLogging;
@@ -105,6 +103,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
state = state.copyWith(lyricsProviders: updatedProviders);
}
if (state.metadataSource != 'deezer' ||
state.spotifyClientId.isNotEmpty ||
state.spotifyClientSecret.isNotEmpty ||
state.useCustomSpotifyCredentials) {
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
@@ -193,49 +202,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
Future<void> _retireBuiltInSpotifyProvider() async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) {
await _secureStorage.write(
key: _spotifyClientSecretKey,
value: prefsSecret,
);
}
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
? storedSecret
: (prefsSecret.isNotEmpty ? prefsSecret : '');
if (effectiveSecret != state.spotifyClientSecret) {
state = state.copyWith(spotifyClientSecret: effectiveSecret);
}
if (prefsSecret.isNotEmpty) {
await _saveSettings();
}
}
Future<void> _storeSpotifyClientSecret(String secret) async {
if (secret.isEmpty) {
if (storedSecret != null && storedSecret.isNotEmpty) {
await _secureStorage.delete(key: _spotifyClientSecretKey);
} else {
await _secureStorage.write(key: _spotifyClientSecretKey, value: secret);
}
}
Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId,
state.spotifyClientSecret,
);
if (state.metadataSource == 'deezer' &&
state.spotifyClientId.isEmpty &&
state.spotifyClientSecret.isEmpty &&
!state.useCustomSpotifyCredentials) {
return;
}
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
await _saveSettings();
}
void setDefaultService(String service) {
@@ -396,45 +384,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setSpotifyClientId(String clientId) {
state = state.copyWith(spotifyClientId: clientId);
_saveSettings();
}
Future<void> setSpotifyClientSecret(String clientSecret) async {
state = state.copyWith(spotifyClientSecret: clientSecret);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
}
Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
state = state.copyWith(
spotifyClientId: clientId,
spotifyClientSecret: clientSecret,
);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
_applySpotifyCredentials();
}
Future<void> clearSpotifyCredentials() async {
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
await _storeSpotifyClientSecret('');
_saveSettings();
_applySpotifyCredentials();
}
void setUseCustomSpotifyCredentials(bool enabled) {
state = state.copyWith(useCustomSpotifyCredentials: enabled);
_saveSettings();
_applySpotifyCredentials();
}
void setMetadataSource(String source) {
state = state.copyWith(metadataSource: source);
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
state = state.copyWith(metadataSource: normalized);
_saveSettings();
}
@@ -532,6 +484,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setLocalLibraryBookmark(String bookmark) {
state = state.copyWith(localLibraryBookmark: bookmark);
_saveSettings();
}
void setLocalLibraryPathAndBookmark(String path, String bookmark) {
state = state.copyWith(
localLibraryPath: path,
localLibraryBookmark: bookmark,
);
_saveSettings();
}
void setLocalLibraryShowDuplicates(bool show) {
state = state.copyWith(localLibraryShowDuplicates: show);
_saveSettings();
+31 -34
View File
@@ -204,7 +204,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Step 1: Check for extension URL handlers first (handles YT Music, etc.)
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -215,7 +214,6 @@ class TrackNotifier extends Notifier<TrackState> {
result = await PlatformBridge.handleURLWithExtension(url);
if (!_isRequestValid(requestId)) return;
// Check if we got valid data
if (result != null &&
result['type'] == 'track' &&
result['track'] != null) {
@@ -321,7 +319,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Step 2: Try Deezer URL parsing
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
_log.i('Detected Deezer URL, parsing...');
final parsed = await PlatformBridge.parseDeezerUrl(url);
@@ -387,7 +384,6 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
// Step 3: Try Tidal URL parsing
if (url.contains('tidal.com')) {
_log.i('Detected Tidal URL, parsing...');
final parsed = await PlatformBridge.parseTidalUrl(url);
@@ -461,7 +457,20 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
// Step 4: Fall back to Spotify parsing
// If URL doesn't match any known service, it's unrecognized
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
url.startsWith('spotify:');
if (!isSpotifyUrl) {
state = TrackState(
isLoading: false,
error: 'url_not_recognized',
hasSearchText: state.hasSearchText,
);
return;
}
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return;
@@ -538,11 +547,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> search(
String query, {
String? metadataSource,
String? filterOverride,
}) async {
Future<void> search(String query, {String? filterOverride}) async {
final requestId = ++_currentRequestId;
// Preserve selected filter during loading
@@ -568,7 +573,7 @@ class TrackNotifier extends Notifier<TrackState> {
searchProvider != null &&
searchProvider.isNotEmpty;
final source = metadataSource ?? 'deezer';
const source = 'deezer';
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
@@ -594,32 +599,20 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
} catch (e) {
_log.w('Extension search failed, falling back to built-in: $e');
_log.w('Extension search failed, falling back to Deezer: $e');
}
}
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
_log.i(
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
);
} else {
_log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(
query,
trackLimit: 20,
artistLimit: 2,
);
_log.i(
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
);
}
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
_log.i(
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
);
if (!_isRequestValid(requestId)) {
_log.w('Search request cancelled (requestId=$requestId)');
@@ -823,6 +816,7 @@ class TrackNotifier extends Notifier<TrackState> {
discNumber: track.discNumber,
releaseDate: track.releaseDate,
albumType: track.albumType,
totalTracks: track.totalTracks,
source: track.source,
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
@@ -904,6 +898,8 @@ class TrackNotifier extends Notifier<TrackState> {
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?,
);
}
@@ -926,6 +922,7 @@ class TrackNotifier extends Notifier<TrackState> {
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source:
source ??
data['source']?.toString() ??
+3 -3
View File
@@ -224,6 +224,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?,
);
}
@@ -305,7 +307,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background (no blur, full resolution)
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl:
@@ -326,7 +327,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -345,7 +345,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
),
),
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,
@@ -491,6 +490,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
+173 -139
View File
@@ -21,7 +21,6 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
/// Simple in-memory cache for artist data
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
@@ -69,7 +68,6 @@ class _CacheEntry {
});
}
/// Artist screen with Spotify-like design
class ArtistScreen extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
@@ -296,7 +294,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
}
Track _parseTrack(Map<String, dynamic> data) {
Track _parseTrack(Map<String, dynamic> data, {ArtistAlbum? album}) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
@@ -309,18 +307,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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(),
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
.toString(),
albumArtist: data['album_artist']?.toString() ?? widget.artistName,
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumId: data['album_id']?.toString() ?? album?.id,
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl)
?.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(),
albumType: data['album_type']?.toString() ?? album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
source: data['provider_id']?.toString(),
);
}
@@ -345,7 +347,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.where((a) => a.albumType == 'album')
.toList(growable: false);
_singlesBucket = albums
.where((a) => a.albumType == 'single')
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
.toList(growable: false);
_compilationsBucket = albums
.where((a) => a.albumType == 'compilation')
@@ -416,6 +418,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
context.l10n.artistSingles,
singles,
colorScheme,
showTypeBadge: true,
),
),
if (compilations.isNotEmpty)
@@ -670,7 +673,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum> albums,
) {
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
final singles = albums.where((a) => a.albumType == 'single').toList();
final singles = albums
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
.toList();
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
final albumTracks = albumsOnly.fold<int>(
@@ -717,7 +722,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
const Divider(height: 1),
// Options
if (albums.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.library_music,
@@ -830,7 +834,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
int failedCount = 0;
for (final album in albums) {
if (!_isFetchingDiscography) break; // Cancelled
if (!_isFetchingDiscography) break;
try {
final tracks = await _fetchAlbumTracks(album);
@@ -942,7 +946,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else if (album.id.startsWith('deezer:')) {
@@ -963,7 +967,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
@@ -972,7 +976,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
}
@@ -1006,6 +1010,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
releaseDate: album.releaseDate,
albumType: album.albumType,
totalTracks: album.totalTracks,
);
}
@@ -1066,7 +1071,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
alignment: Alignment.topCenter, // Show top of image (faces)
alignment: Alignment.topCenter,
memCacheWidth: 800,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) =>
@@ -1155,7 +1160,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
),
// Download Discography button (icon only, right-aligned)
if (hasDiscography && !_isSelectionMode) ...[
const SizedBox(width: 12),
Container(
@@ -1188,6 +1192,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -1201,7 +1206,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
/// Build Popular tracks section like Spotify
Widget _buildPopularSection(ColorScheme colorScheme) {
if (_topTracks == null || _topTracks!.isEmpty) {
return const SizedBox.shrink();
@@ -1416,7 +1420,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
/// Handle tap on popular track item
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
if (isQueued) return;
@@ -1528,8 +1531,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Widget _buildAlbumSection(
String title,
List<ArtistAlbum> albums,
ColorScheme colorScheme,
) {
ColorScheme colorScheme, {
bool showTypeBadge = false,
}) {
final sectionHeight = _artistAlbumSectionHeight();
final tileSize = _artistAlbumTileSize();
@@ -1560,6 +1564,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
colorScheme,
tileSize: tileSize,
sectionHeight: sectionHeight,
showTypeBadge: showTypeBadge,
),
);
},
@@ -1574,47 +1579,65 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
ColorScheme colorScheme, {
required double tileSize,
required double sectionHeight,
bool showTypeBadge = false,
}) {
final isSelected = _selectedAlbumIds.contains(album.id);
return GestureDetector(
onTap: () {
if (_isSelectionMode) {
_toggleAlbumSelection(album.id);
} else {
_navigateToAlbum(album);
}
},
onLongPress: () {
if (!_isSelectionMode) {
_enterSelectionMode(album.id);
}
},
child: Container(
width: tileSize,
height: sectionHeight,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
width: tileSize,
height: tileSize,
fit: BoxFit.cover,
memCacheWidth: (tileSize * 2).round(),
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
return Semantics(
button: true,
selected: _isSelectionMode && isSelected,
label: _isSelectionMode
? 'Select album ${album.name}'
: 'Open album ${album.name}',
child: GestureDetector(
onTap: () {
if (_isSelectionMode) {
_toggleAlbumSelection(album.id);
} else {
_navigateToAlbum(album);
}
},
onLongPress: () {
if (!_isSelectionMode) {
_enterSelectionMode(album.id);
}
},
child: Container(
width: tileSize,
height: sectionHeight,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
fit: BoxFit.cover,
memCacheWidth: (tileSize * 2).round(),
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 40,
),
),
)
: Container(
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
@@ -1624,99 +1647,110 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
size: 40,
),
),
)
: Container(
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 40,
),
if (_isSelectionMode)
Positioned.fill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
border: isSelected
? Border.all(color: colorScheme.primary, width: 3)
: null,
),
),
),
if (_isSelectionMode)
Positioned(
top: 8,
right: 8,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
),
// Selection overlay
if (_isSelectionMode)
Positioned.fill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
border: isSelected
? Border.all(color: colorScheme.primary, width: 3)
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 18,
)
: null,
),
),
),
// Checkbox
if (_isSelectionMode)
Positioned(
top: 8,
right: 8,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
if (showTypeBadge)
Positioned(
left: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: Text(
album.albumType == 'ep' ? 'EP' : 'Single',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 18,
)
: null,
),
),
],
),
const SizedBox(height: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 2),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4
? album.releaseDate.substring(0, 4)
: album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
const SizedBox(height: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 2),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4
? album.releaseDate.substring(0, 4)
: album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
);
+5 -11
View File
@@ -18,7 +18,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
/// Screen to display downloaded tracks from a specific album
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
@@ -361,7 +360,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
final tracks = _getAlbumTracks(allHistoryItems);
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
@@ -480,7 +478,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (embeddedCoverPath != null)
Image.file(
File(embeddedCoverPath),
@@ -508,7 +505,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -527,7 +523,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
),
),
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,
@@ -635,6 +630,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -711,10 +707,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final discTracks = discMap[discNumber];
if (discTracks == null || discTracks.isEmpty) continue;
// Add disc separator
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
// Add tracks for this disc
for (final track in discTracks) {
children.add(
KeyedSubtree(
@@ -858,6 +852,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
trailing: _isSelectionMode
? null
: IconButton(
tooltip: 'Play track',
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
@@ -897,7 +892,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
// Share SAF content URIs via native intent
if (safUris.isNotEmpty) {
try {
if (safUris.length == 1) {
@@ -908,13 +902,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} catch (_) {}
}
// Share regular files via SharePlus
if (filesToShare.isNotEmpty) {
await SharePlus.instance.share(ShareParams(files: filesToShare));
}
}
/// Show batch convert bottom sheet
void _showBatchConvertSheet(
BuildContext context,
List<DownloadHistoryItem> allTracks,
@@ -1336,6 +1328,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -1388,7 +1383,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
const SizedBox(height: 12),
// Action buttons row: Share, Convert
Row(
children: [
Expanded(
+188 -132
View File
@@ -520,7 +520,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider;
// Use filterOverride if provided, otherwise read from state
final selectedFilter =
filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
@@ -535,7 +534,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
if (isExtensionEnabled) {
// Build options with filter if selected
Map<String, dynamic>? options;
if (selectedFilter != null) {
options = {'filter': selectedFilter};
@@ -551,11 +549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
await ref
.read(trackProvider.notifier)
.search(
query,
metadataSource: settings.metadataSource,
filterOverride: selectedFilter,
);
.search(query, filterOverride: selectedFilter);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -585,12 +579,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
_navigateToDetailIfNeeded();
final trackState = ref.read(trackProvider);
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit =
errorMsg.contains('429') ||
errorMsg.toLowerCase().contains('rate limit') ||
errorMsg.toLowerCase().contains('too many requests');
final displayMessage = errorMsg == 'url_not_recognized'
? l10n.errorUrlNotRecognizedMessage
: isRateLimit
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(displayMessage)));
ref.read(trackProvider.notifier).clear();
} else {
_navigateToDetailIfNeeded();
}
} else {
final settings = ref.read(settingsProvider);
await ref
.read(trackProvider.notifier)
.search(url, metadataSource: settings.metadataSource);
await ref.read(trackProvider.notifier).search(url);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -1116,7 +1126,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
// Search filter bar (only shown when has search results)
if (hasActualResults && !showRecentAccess)
Consumer(
builder: (context, ref, _) {
@@ -1265,7 +1274,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
(searchArtists != null && searchArtists.isNotEmpty) ||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty) ||
isLoading;
isLoading ||
error != null;
return SliverMainAxisGroup(
slivers: _buildSearchResults(
@@ -1286,8 +1296,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
],
),
), // Close RefreshIndicator
), // Close GestureDetector
),
),
);
}
@@ -1335,24 +1345,49 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
return KeyedSubtree(
key: ValueKey(item.id),
child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item),
child: Container(
width: coverSize,
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
cacheWidth: (coverSize * 2).round(),
cacheHeight: (coverSize * 2).round(),
errorBuilder: (_, _, _) => Container(
child: Semantics(
button: true,
label: 'Open track ${item.trackName} by ${item.artistName}',
child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item),
child: Container(
width: coverSize,
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
cacheWidth: (coverSize * 2).round(),
cacheHeight: (coverSize * 2).round(),
errorBuilder: (_, _, _) => Container(
width: coverSize,
height: coverSize,
color:
colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 32,
),
),
)
: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).round(),
memCacheHeight: (coverSize * 2).round(),
cacheManager: CoverCacheManager.instance,
)
: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
@@ -1362,38 +1397,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
size: 32,
),
),
)
: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).round(),
memCacheHeight: (coverSize * 2).round(),
cacheManager: CoverCacheManager.instance,
)
: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 32,
),
),
),
const SizedBox(height: 6),
Text(
item.trackName,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
const SizedBox(height: 6),
Text(
item.trackName,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
),
),
@@ -1434,7 +1449,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
return _buildExploreSection(sections[sectionIndex], colorScheme);
}
// Bottom padding
return const SizedBox(height: 16);
}, childCount: totalCount),
),
@@ -1498,31 +1512,45 @@ class _HomeTabState extends ConsumerState<HomeTab>
final cardSize = _exploreCardSize(context);
final iconSize = cardSize * 0.3;
return GestureDetector(
onTap: () => _navigateToExploreItem(item),
child: SizedBox(
width: cardSize,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
crossAxisAlignment: isArtist
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 8,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
memCacheWidth: (cardSize * 2).round(),
memCacheHeight: (cardSize * 2).round(),
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Container(
return Semantics(
button: true,
label: 'Open ${item.type} ${item.name}',
child: GestureDetector(
onTap: () => _navigateToExploreItem(item),
child: SizedBox(
width: cardSize,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
crossAxisAlignment: isArtist
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 8,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
memCacheWidth: (cardSize * 2).round(),
memCacheHeight: (cardSize * 2).round(),
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
_getIconForType(item.type),
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
),
)
: Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
@@ -1532,42 +1560,32 @@ class _HomeTabState extends ConsumerState<HomeTab>
size: iconSize,
),
),
)
: Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
_getIconForType(item.type),
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
),
),
const SizedBox(height: 8),
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: isArtist ? TextAlign.center : TextAlign.start,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
if (item.artists.isNotEmpty && !isArtist)
ClickableArtistName(
artistName: item.artists,
coverUrl: item.coverUrl,
extensionId: item.providerId,
const SizedBox(height: 8),
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: isArtist ? TextAlign.center : TextAlign.start,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
],
if (item.artists.isNotEmpty && !isArtist)
ClickableArtistName(
artistName: item.artists,
coverUrl: item.coverUrl,
extensionId: item.providerId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
),
),
),
@@ -2022,6 +2040,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
IconButton(
tooltip: 'Dismiss',
icon: Icon(
Icons.close,
size: 20,
@@ -2218,11 +2237,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final l10n = context.l10n;
final isRateLimit =
error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
final isUrlNotRecognized = error == 'url_not_recognized';
if (isRateLimit) {
return Card(
elevation: 0,
@@ -2239,7 +2261,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -2247,7 +2269,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment before searching again.',
l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -2262,6 +2284,42 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
if (isUrlNotRecognized) {
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.link_off, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.errorUrlNotRecognized,
style: TextStyle(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
l10n.errorUrlNotRecognizedMessage,
style: TextStyle(color: colorScheme.error, fontSize: 12),
),
],
),
),
],
),
),
);
}
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -2273,7 +2331,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(error, style: TextStyle(color: colorScheme.error)),
child: Text(
l10n.errorUrlFetchFailed,
style: TextStyle(color: colorScheme.error),
),
),
],
),
@@ -2705,7 +2766,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
scrollDirection: Axis.horizontal,
child: Row(
children: [
// "All" chip (no filter)
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
@@ -2728,7 +2788,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
),
// Filter chips from extension
...filters.map((filter) {
final isSelected = selectedFilter == filter.id;
return Padding(
@@ -2830,7 +2889,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
prefixIcon: _SearchProviderDropdown(
onProviderChanged: () {
_lastSearchQuery = null;
// Reset filter when provider changes
ref.read(trackProvider.notifier).setSearchFilter(null);
setState(() {});
final text = _urlController.text.trim();
@@ -2904,9 +2962,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
final currentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final metadataSource = ref.watch(
settingsProvider.select((s) => s.metadataSource),
);
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
final colorScheme = Theme.of(context).colorScheme;
@@ -2984,7 +3039,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
metadataSource == 'spotify' ? 'Spotify' : 'Deezer',
'Deezer',
style: TextStyle(
fontWeight:
currentProvider == null || currentProvider.isEmpty
@@ -4386,6 +4441,7 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
),
),
IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
icon: Icon(
Icons.more_vert,
color: widget.colorScheme.onSurfaceVariant,
+1 -4
View File
@@ -32,6 +32,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
@@ -158,7 +159,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header: drag handle + thumbnail + playlist info
Column(
children: [
const SizedBox(height: 8),
@@ -210,7 +210,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
// Rename
_PlaylistOptionTile(
icon: Icons.edit_outlined,
title: context.l10n.collectionRenamePlaylist,
@@ -225,7 +224,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
// Change cover
_PlaylistOptionTile(
icon: Icons.image_outlined,
title: context.l10n.collectionPlaylistChangeCover,
@@ -235,7 +233,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
// Delete
_PlaylistOptionTile(
icon: Icons.delete_outline,
iconColor: colorScheme.error,
+9 -15
View File
@@ -37,7 +37,6 @@ class _LibraryTracksFolderScreenState
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
// ── Multi-select state ──
bool _isSelectionMode = false;
final Set<String> _selectedKeys = {};
@@ -145,8 +144,6 @@ class _LibraryTracksFolderScreenState
return url;
}
// ── Selection helpers ──
void _enterSelectionMode(String key) {
HapticFeedback.mediumImpact();
setState(() {
@@ -181,8 +178,6 @@ class _LibraryTracksFolderScreenState
});
}
// ── Batch actions ──
Future<void> _removeSelected(List<CollectionTrackEntry> entries) async {
final keysToRemove = _selectedKeys.toSet();
if (keysToRemove.isEmpty) return;
@@ -426,7 +421,6 @@ class _LibraryTracksFolderScreenState
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 32,
height: 4,
@@ -437,11 +431,13 @@ class _LibraryTracksFolderScreenState
),
),
// Header: [X close] [count] [Select All / Deselect]
Row(
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -493,7 +489,6 @@ class _LibraryTracksFolderScreenState
const SizedBox(height: 12),
// Action buttons row
Row(
children: [
if (isWishlist)
@@ -525,7 +520,6 @@ class _LibraryTracksFolderScreenState
const SizedBox(height: 8),
// Remove button (full width, red)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
@@ -714,7 +708,6 @@ class _LibraryTracksFolderScreenState
)
else
coverFallback,
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -733,7 +726,6 @@ class _LibraryTracksFolderScreenState
),
),
),
// Title and track count overlay
Positioned(
left: 20,
right: 20,
@@ -811,6 +803,9 @@ class _LibraryTracksFolderScreenState
},
),
leading: IconButton(
tooltip: _isSelectionMode
? MaterialLocalizations.of(context).closeButtonTooltip
: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -829,9 +824,8 @@ class _LibraryTracksFolderScreenState
);
}
// ── Header actions ──
Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48);
Widget _buildHeaderActionPlaceholder() =>
const SizedBox(width: 48, height: 48);
Widget _buildDownloadAllCenterButton(List<CollectionTrackEntry> entries) {
final tracks = entries.map((e) => e.track).toList(growable: false);
@@ -1152,6 +1146,7 @@ class _CollectionTrackTile extends ConsumerWidget {
trailing: isSelectionMode
? null
: IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
icon: Icon(
Icons.more_vert,
color: colorScheme.onSurfaceVariant,
@@ -1263,7 +1258,6 @@ class _CollectionTrackTile extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header: drag handle + cover + track info
Column(
children: [
const SizedBox(height: 8),
+29 -20
View File
@@ -13,7 +13,6 @@ 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';
/// Screen to display tracks from a local library album
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
@@ -39,6 +38,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache;
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(cueVirtualTrackRequiresSplitMessage),
),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@@ -83,7 +90,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
@@ -180,9 +186,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
for (final id in idsToDelete) {
final item = tracksById[id];
if (item != null) {
try {
await deleteFile(item.filePath);
} catch (_) {}
if (!isCueVirtualPath(item.filePath)) {
try {
await deleteFile(item.filePath);
} catch (_) {}
}
await libraryNotifier.removeItem(id);
deletedCount++;
}
@@ -197,7 +205,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
);
// Go back if all tracks were deleted
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
}
@@ -206,6 +213,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
Future<void> _openFile(LocalLibraryItem track) async {
if (isCueVirtualPath(track.filePath)) {
_showCueVirtualTrackSnackBar();
return;
}
try {
await ref
.read(playbackProvider.notifier)
@@ -233,7 +244,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracksCache;
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
@@ -326,7 +336,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (widget.coverPath != null)
Image.file(
File(widget.coverPath!),
@@ -343,7 +352,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -362,7 +370,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
),
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,
@@ -494,6 +501,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -733,6 +741,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
trailing: _isSelectionMode
? null
: IconButton(
tooltip: 'Play track',
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
@@ -888,7 +897,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return false;
}
/// Batch re-enrich selected local tracks
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@@ -958,13 +966,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim();
final settings = ref.read(settingsProvider);
final localLibraryPath = settings.localLibraryPath.trim();
final iosBookmark = settings.localLibraryBookmark;
try {
if (localLibraryPath.isNotEmpty &&
!ref.read(localLibraryProvider).isScanning) {
await ref
.read(localLibraryProvider.notifier)
.startScan(localLibraryPath);
.startScan(
localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
@@ -988,7 +1001,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
).showSnackBar(SnackBar(content: Text(summary)));
}
/// Show batch convert bottom sheet
void _showBatchConvertSheet(
BuildContext context,
List<LocalLibraryItem> allTracks,
@@ -1261,7 +1273,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
String? safTempPath;
if (isSaf) {
// Copy SAF file to temp for conversion
safTempPath = await PlatformBridge.copyContentUriToTemp(
item.filePath,
);
@@ -1296,7 +1307,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (isSaf) {
// For SAF: derive the parent tree URI and relative dir from the content URI,
// then create new SAF file and delete old one
//
// Parse the SAF URI to get the tree document path:
// content://...tree/...document/.../oldName.flac
// We need tree URI and relative dir to create the new file
@@ -1375,14 +1385,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
continue;
}
// Delete old SAF file
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
await localDb.deleteByPath(item.filePath);
}
// Clean up temp files
try {
await File(newPath).delete();
} catch (_) {}
@@ -1400,7 +1408,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (_) {}
}
// Reload local library to pick up converted files
ref.read(localLibraryProvider.notifier).reloadFromStorage();
_exitSelectionMode();
@@ -1461,6 +1468,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -1513,7 +1523,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
const SizedBox(height: 12),
// Action buttons row: Re-enrich, Convert
Row(
children: [
Expanded(
+17 -1
View File
@@ -102,13 +102,29 @@ class _MainShellState extends ConsumerState<MainShell> {
if (_currentIndex != 0) {
_onNavTap(0);
}
ref.read(trackProvider.notifier).fetchFromUrl(url);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink)));
}
await ref.read(trackProvider.notifier).fetchFromUrl(url);
final trackState = ref.read(trackProvider);
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit = errorMsg.contains('429') ||
errorMsg.toLowerCase().contains('rate limit') ||
errorMsg.toLowerCase().contains('too many requests');
final displayMessage = errorMsg == 'url_not_recognized'
? l10n.errorUrlNotRecognizedMessage
: isRateLimit
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(displayMessage)),
);
}
}
Future<void> _checkForUpdates() async {
+5 -9
View File
@@ -206,7 +206,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl:
@@ -227,7 +226,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -246,7 +244,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
),
),
),
// Playlist info overlay at bottom
Positioned(
left: 20,
right: 20,
@@ -324,6 +321,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -418,7 +416,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
.addToQueue(track, service, qualityOverride: quality, playlistName: widget.playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
@@ -429,15 +427,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
.addToQueue(track, settings.defaultService, playlistName: widget.playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
}
// ── Shuffle / Love / Download buttons ──
Widget _buildCircleButton({
required IconData icon,
required String tooltip,
@@ -590,7 +586,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: widget.playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -603,7 +599,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
.addMultipleToQueue(tracks, settings.defaultService, playlistName: widget.playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
+358 -274
View File
@@ -29,6 +29,8 @@ import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
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';
enum LibraryItemSource { downloaded, local }
@@ -70,7 +72,11 @@ class UnifiedLibraryItem {
albumName: item.albumName,
coverUrl: item.coverUrl,
filePath: item.filePath,
quality: item.quality,
quality: buildDisplayAudioQuality(
bitDepth: item.bitDepth,
sampleRate: item.sampleRate,
storedQuality: item.quality,
),
addedAt: item.downloadedAt,
source: LibraryItemSource.downloaded,
historyItem: item,
@@ -80,15 +86,18 @@ class UnifiedLibraryItem {
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
String? quality;
if (item.bitrate != null && item.bitrate! > 0) {
// Lossy format with bitrate
final fmt = item.format?.toUpperCase() ?? '';
quality = '$fmt ${item.bitrate}kbps'.trim();
quality = buildDisplayAudioQuality(
bitrateKbps: item.bitrate,
format: item.format,
);
} else if (item.bitDepth != null &&
item.bitDepth! > 0 &&
item.sampleRate != null) {
// Lossless format with actual bit depth
quality =
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
quality = buildDisplayAudioQuality(
bitDepth: item.bitDepth,
sampleRate: item.sampleRate,
);
}
return UnifiedLibraryItem(
id: 'local_${item.id}',
@@ -211,7 +220,7 @@ class _GroupedAlbum {
class _GroupedLocalAlbum {
final String albumName;
final String artistName;
final String? coverPath; // Local cover file path
final String? coverPath;
final List<LocalLibraryItem> tracks;
final DateTime latestScanned;
final String searchKey;
@@ -229,12 +238,11 @@ class _GroupedLocalAlbum {
class _HistoryStats {
final Map<String, int> albumCounts;
final Map<String, int> localAlbumCounts; // For identifying local singles
final Map<String, int> localAlbumCounts;
final List<_GroupedAlbum> groupedAlbums;
final List<_GroupedLocalAlbum> groupedLocalAlbums; // Local library albums
final List<_GroupedLocalAlbum> groupedLocalAlbums;
final int albumCount;
final int singleTracks;
// Local library stats
final int localAlbumCount;
final int localSingleTracks;
@@ -933,8 +941,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
overlay.insert(_playlistSelectionOverlayEntry!);
}
// --- Playlist selection mode ---
void _enterPlaylistSelectionMode(String playlistId) {
HapticFeedback.mediumImpact();
setState(() {
@@ -1059,6 +1065,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
children: [
IconButton.filledTonal(
onPressed: _exitPlaylistSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -1202,11 +1211,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
await deleteFile(cleanPath);
} catch (_) {}
// Remove from appropriate database
if (item.source == LibraryItemSource.downloaded) {
historyNotifier.removeFromHistory(item.historyItem!.id);
} else {
// Remove from local library database
await localLibraryDb.deleteByPath(item.filePath);
}
deletedCount++;
@@ -2024,7 +2031,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Map<String, int> albumCounts, [
String searchQuery = '',
]) {
// First apply search filter
var filteredItems = items;
if (searchQuery.isNotEmpty) {
final query = searchQuery;
@@ -2034,7 +2040,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}).toList();
}
// Then apply filter mode
if (filterMode == 'all') return filteredItems;
switch (filterMode) {
@@ -2108,10 +2113,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (count > 1) albumCount++;
}
final downloadedPathKeys = <String>{};
for (final item in items) {
downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
}
final dedupedLocalItems = localItems
.where((item) {
final localPathKeys = buildPathMatchKeys(item.filePath);
return !localPathKeys.any(downloadedPathKeys.contains);
})
.toList(growable: false);
// Calculate local library stats
final localAlbumCounts = <String, int>{};
final localAlbumMap = <String, List<LocalLibraryItem>>{};
for (final item in localItems) {
for (final item in dedupedLocalItems) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1;
@@ -2639,7 +2656,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Search bar - always at top
if (allHistoryItems.isNotEmpty ||
hasQueueItems ||
localLibraryItems.isNotEmpty)
@@ -2838,8 +2854,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
.map((item) => UnifiedLibraryItem.fromLocalLibrary(item))
.toList(growable: false);
final merged = <UnifiedLibraryItem>[...unifiedDownloaded, ...unifiedLocal]
..sort((a, b) => b.addedAt.compareTo(a.addedAt));
final downloadedPathKeys = <String>{};
for (final item in unifiedDownloaded) {
downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
}
final dedupedUnifiedLocal = <UnifiedLibraryItem>[];
for (final item in unifiedLocal) {
final localPathKeys = buildPathMatchKeys(item.filePath);
final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains);
if (!overlapsDownloaded) {
dedupedUnifiedLocal.add(item);
}
}
final merged = <UnifiedLibraryItem>[
...unifiedDownloaded,
...dedupedUnifiedLocal,
]..sort((a, b) => b.addedAt.compareTo(a.addedAt));
_unifiedItemsCache[filterMode] = _UnifiedCacheEntry(
historyItems: historyItems,
@@ -3068,37 +3100,41 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
);
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: cover,
return Semantics(
button: true,
label: 'Open $title, $count ${count == 1 ? 'item' : 'items'}',
child: GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: cover,
),
),
),
const SizedBox(height: 6),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
Text(
'$count ${count == 1 ? 'item' : 'items'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 6),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
),
],
Text(
'$count ${count == 1 ? 'item' : 'items'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@@ -3913,112 +3949,117 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
album.sampleFilePath,
);
return GestureDetector(
onTap: () => _navigateToDownloadedAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
cacheWidth: 300,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
return Semantics(
button: true,
label:
'Open album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks',
child: GestureDetector(
onTap: () => _navigateToDownloadedAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
cacheWidth: 300,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
)
: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 300,
memCacheHeight: 300,
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
)
: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 300,
memCacheHeight: 300,
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
),
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.music_note,
size: 12,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
),
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.music_note,
size: 12,
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
],
],
),
),
),
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
ClickableArtistName(
artistName: album.artistName,
coverUrl: album.coverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
],
ClickableArtistName(
artistName: album.artistName,
coverUrl: album.coverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@@ -4029,102 +4070,107 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_GroupedLocalAlbum album,
ColorScheme colorScheme,
) {
return GestureDetector(
onTap: () => _navigateToLocalAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverPath != null
? Image.file(
File(album.coverPath!),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
cacheWidth: 300,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
return Semantics(
button: true,
label:
'Open local album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks',
child: GestureDetector(
onTap: () => _navigateToLocalAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverPath != null
? Image.file(
File(album.coverPath!),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
cacheWidth: 300,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
),
// "Local" badge instead of track count
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder,
size: 12,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
),
// "Local" badge instead of track count
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder,
size: 12,
color: colorScheme.onTertiaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
],
],
),
),
),
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
ClickableArtistName(
artistName: album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
],
ClickableArtistName(
artistName: album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@@ -4356,13 +4402,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return;
}
final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim();
final settings = ref.read(settingsProvider);
final localLibraryPath = settings.localLibraryPath.trim();
final iosBookmark = settings.localLibraryBookmark;
try {
if (localLibraryPath.isNotEmpty &&
!ref.read(localLibraryProvider).isScanning) {
await ref
.read(localLibraryProvider.notifier)
.startScan(localLibraryPath);
.startScan(
localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
@@ -4996,6 +5047,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -5275,18 +5329,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
);
case DownloadStatus.finalizing:
return SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
return Semantics(
label: 'Finalizing download',
child: SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
),
ExcludeSemantics(
child: Icon(
Icons.edit_note,
color: colorScheme.tertiary,
size: 16,
),
),
],
),
),
);
case DownloadStatus.completed:
@@ -5314,18 +5377,32 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
)
else
Icon(Icons.error_outline, color: colorScheme.error, size: 20),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
Semantics(
label: 'Downloaded file missing',
child: ExcludeSemantics(
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 20,
),
),
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: 20,
const SizedBox(width: 4),
Semantics(
label: 'Download completed',
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
),
),
],
@@ -5888,27 +5965,34 @@ class _QueueTabState extends ConsumerState<QueueTab> {
valueListenable: fileExistsListenable,
builder: (context, fileExists, child) {
return fileExists
? GestureDetector(
onTap: () => _openFile(
item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
coverUrl:
item.coverUrl ??
item.localCoverPath ??
'',
),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
? Semantics(
button: true,
label:
'Play ${item.trackName} by ${item.artistName}',
child: GestureDetector(
onTap: () => _openFile(
item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
coverUrl:
item.coverUrl ??
item.localCoverPath ??
'',
),
child: Icon(
Icons.play_arrow,
color: colorScheme.onPrimary,
size: 16,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: ExcludeSemantics(
child: Icon(
Icons.play_arrow,
color: colorScheme.onPrimary,
size: 16,
),
),
),
),
)
+7 -9
View File
@@ -27,10 +27,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
_searchController = TextEditingController(text: widget.query);
if (widget.query.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final settings = ref.read(settingsProvider);
ref
.read(trackProvider.notifier)
.search(widget.query, metadataSource: settings.metadataSource);
ref.read(trackProvider.notifier).search(widget.query);
});
}
}
@@ -44,10 +41,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
void _search() {
final query = _searchController.text.trim();
if (query.isNotEmpty) {
final settings = ref.read(settingsProvider);
ref
.read(trackProvider.notifier)
.search(query, metadataSource: settings.metadataSource);
ref.read(trackProvider.notifier).search(query);
}
}
@@ -84,7 +78,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
autofocus: widget.query.isEmpty,
),
actions: [
IconButton(icon: const Icon(Icons.search), onPressed: _search),
IconButton(
tooltip: MaterialLocalizations.of(context).searchFieldLabel,
icon: const Icon(Icons.search),
onPressed: _search,
),
],
),
body: Column(
+2 -4
View File
@@ -28,6 +28,7 @@ class AboutPage extends StatelessWidget {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
@@ -49,7 +50,7 @@ class AboutPage extends StatelessWidget {
title: Text(
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
@@ -462,7 +463,6 @@ class _ContributorItem extends StatelessWidget {
}
}
/// Translator data model
class _Translator {
final String name;
final String crowdinUsername;
@@ -477,7 +477,6 @@ class _Translator {
});
}
/// Translators section with compact chip-style layout
class _TranslatorsSection extends StatelessWidget {
const _TranslatorsSection();
@@ -558,7 +557,6 @@ class _TranslatorsSection extends StatelessWidget {
}
}
/// Individual translator chip
class _TranslatorChip extends StatelessWidget {
final _Translator translator;
@@ -30,6 +30,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
@@ -148,7 +149,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
}
}
/// A simplified preview of how the app looks with current settings
class _ThemePreviewCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
@@ -348,11 +348,21 @@ class _ColorPalettePicker extends StatelessWidget {
child: Row(
children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
final colorHex = color
.toARGB32()
.toRadixString(16)
.padLeft(8, '0')
.toUpperCase();
return Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: () => onColorSelected(color),
child: _ColorPaletteItem(color: color, isSelected: isSelected),
child: Semantics(
button: true,
selected: isSelected,
label: 'Select accent color $colorHex',
child: GestureDetector(
onTap: () => onColorSelected(color),
child: _ColorPaletteItem(color: color, isSelected: isSelected),
),
),
);
}).toList(),
@@ -423,7 +433,6 @@ class _ColorPaletteItem extends StatelessWidget {
}
}
/// Optimized app bar title with animation
class _AppBarTitle extends StatelessWidget {
final String title;
final double topPadding;
@@ -440,14 +449,14 @@ class _AppBarTitle extends StatelessWidget {
final expandRatio =
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
title,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
@@ -311,9 +312,12 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
final orphanedDownloads = await ref
.read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads();
final iosBookmark = ref.read(settingsProvider).localLibraryBookmark;
final missingLibraryEntries = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles();
.cleanupMissingFiles(
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -384,11 +388,13 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
),
+16 -19
View File
@@ -24,6 +24,7 @@ class DonatePage extends StatelessWidget {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
@@ -56,17 +57,14 @@ class DonatePage extends StatelessWidget {
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Donate links card
_DonateLinksCard(colorScheme: colorScheme),
const SizedBox(height: 24),
// Recent donors section
_RecentDonorsCard(colorScheme: colorScheme),
const SizedBox(height: 16),
// Combined notice card
Card(
elevation: 0,
color: colorScheme.secondaryContainer.withValues(
@@ -166,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>[];
const donorNames = <String>['a fan'];
// Match SettingsGroup color logic
final cardColor = isDark
@@ -218,13 +216,17 @@ class _RecentDonorsCard extends StatelessWidget {
Icon(
Icons.emoji_events_outlined,
size: 32,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
),
const SizedBox(height: 8),
Text(
'No supporters yet — be the first!',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
),
],
@@ -471,9 +473,12 @@ class _CryptoWalletItem extends StatelessWidget {
int _cr(String v) {
int r = 0x1F;
for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; }
for (final c in v.codeUnits) {
r = (r * 31 + c) & 0x7FFFFFFF;
}
return r;
}
// Highlighted supporters (hashes of names): none for now.
const _cv = <int>{};
@@ -490,16 +495,10 @@ class _SupporterChip extends StatelessWidget {
const goldAccentColor = Color(0xFFB8860B);
const goldDarkChipColor = Color(0xFF3A3000);
final chipColor = e
? goldChipColor
: colorScheme.secondaryContainer;
final accentColor = e
? goldAccentColor
: colorScheme.primary;
final chipColor = e ? goldChipColor : colorScheme.secondaryContainer;
final accentColor = e ? goldAccentColor : colorScheme.primary;
final isDark = Theme.of(context).brightness == Brightness.dark;
final effectiveChipColor = e && isDark
? goldDarkChipColor
: chipColor;
final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor;
return Material(
color: effectiveChipColor,
@@ -536,9 +535,7 @@ class _SupporterChip extends StatelessWidget {
Text(
name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: e
? accentColor
: colorScheme.onSecondaryContainer,
color: e ? accentColor : colorScheme.onSecondaryContainer,
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
),
),
@@ -23,7 +23,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer'];
static const _builtInServices = ['tidal', 'qobuz', 'deezer'];
static const _songLinkRegions = [
'AD',
'AE',
@@ -315,6 +315,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
@@ -326,7 +327,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
@@ -336,7 +337,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
title: Text(
context.l10n.downloadTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
@@ -450,7 +451,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Select Tidal, Qobuz, or Amazon above to configure quality',
'Select Tidal or Qobuz above to configure quality',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -732,7 +733,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
// Download Network Mode
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
),
@@ -790,7 +790,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
// All Files Access section (Android 13+ only)
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(
@@ -1418,6 +1417,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String _getFolderOrganizationLabel(String value) {
switch (value) {
case 'playlist':
return 'By Playlist';
case 'artist':
return 'By Artist';
case 'album':
@@ -1995,6 +1996,18 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Navigator.pop(context);
},
),
_FolderOption(
title: context.l10n.folderOrganizationByPlaylist,
subtitle: context.l10n.folderOrganizationByPlaylistSubtitle,
example: 'SpotiFLAC/Playlist Name/Track.flac',
isSelected: current == 'playlist',
onTap: () {
ref
.read(settingsProvider.notifier)
.setFolderOrganization('playlist');
Navigator.pop(context);
},
),
_FolderOption(
title: context.l10n.folderOrganizationByArtist,
subtitle: context.l10n.folderOrganizationByArtistSubtitle,
@@ -2051,7 +2064,7 @@ class _ServiceSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube'];
final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube'];
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
@@ -2088,15 +2101,6 @@ class _ServiceSelector extends ConsumerWidget {
),
),
const SizedBox(width: 8),
Expanded(
child: _ServiceChip(
icon: Icons.shopping_bag_outlined,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
onTap: () => onChanged('amazon'),
),
),
const SizedBox(width: 8),
Expanded(
child: _ServiceChip(
icon: Icons.smart_display,
+390 -348
View File
@@ -15,7 +15,8 @@ class ExtensionDetailPage extends ConsumerStatefulWidget {
const ExtensionDetailPage({super.key, required this.extensionId});
@override
ConsumerState<ExtensionDetailPage> createState() => _ExtensionDetailPageState();
ConsumerState<ExtensionDetailPage> createState() =>
_ExtensionDetailPageState();
}
class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
@@ -65,320 +66,373 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
extension.displayName,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
),
);
},
title: Text(
extension.displayName,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: hasError
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: extension.iconPath != null && extension.iconPath!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
File(extension.iconPath!),
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
hasError ? Icons.error_outline : Icons.extension,
size: 28,
color: hasError
? colorScheme.error
: colorScheme.onPrimaryContainer,
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: hasError
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child:
extension.iconPath != null &&
extension.iconPath!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
File(extension.iconPath!),
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Icon(
hasError
? Icons.error_outline
: Icons.extension,
size: 28,
color: hasError
? colorScheme.error
: colorScheme
.onPrimaryContainer,
),
),
)
: Icon(
hasError
? Icons.error_outline
: Icons.extension,
size: 28,
color: hasError
? colorScheme.error
: colorScheme.onPrimaryContainer,
),
)
: Icon(
hasError ? Icons.error_outline : Icons.extension,
size: 28,
color: hasError
? colorScheme.error
: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
extension.displayName,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'v${extension.version}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Switch(
value: extension.enabled,
onChanged: hasError
? null
: (enabled) => ref
.read(extensionProvider.notifier)
.setExtensionEnabled(widget.extensionId, enabled),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
extension.displayName,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
'v${extension.version}',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Switch(
value: extension.enabled,
onChanged: hasError
? null
: (enabled) => ref
.read(extensionProvider.notifier)
.setExtensionEnabled(
widget.extensionId,
enabled,
),
),
],
),
if (extension.description.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
extension.description,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
if (extension.description.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
extension.description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 16),
_InfoRow(label: context.l10n.extensionAuthor, value: extension.author),
_InfoRow(label: context.l10n.extensionId, value: extension.id),
_InfoRow(label: context.l10n.extensionsVersion(extension.version), value: ''),
if (hasError && extension.errorMessage != null)
_InfoRow(
label: context.l10n.extensionError,
value: extension.errorMessage!,
isError: true,
label: context.l10n.extensionAuthor,
value: extension.author,
),
],
_InfoRow(
label: context.l10n.extensionId,
value: extension.id,
),
_InfoRow(
label: context.l10n.extensionsVersion(
extension.version,
),
value: '',
),
if (hasError && extension.errorMessage != null)
_InfoRow(
label: context.l10n.extensionError,
value: extension.errorMessage!,
isError: true,
),
],
),
),
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_CapabilityItem(
icon: Icons.search,
title: context.l10n.extensionMetadataProvider,
enabled: extension.hasMetadataProvider,
),
_CapabilityItem(
icon: Icons.download,
title: context.l10n.extensionDownloadProvider,
enabled: extension.hasDownloadProvider,
),
_CapabilityItem(
icon: Icons.lyrics,
title: context.l10n.extensionLyricsProvider,
enabled: extension.hasLyricsProvider,
),
_CapabilityItem(
icon: Icons.manage_search,
title: context.l10n.extensionsSearchProvider,
enabled: extension.hasCustomSearch,
subtitle: extension.searchBehavior?.placeholder,
),
_CapabilityItem(
icon: Icons.compare_arrows,
title: context.l10n.extensionCustomTrackMatching,
enabled: extension.hasCustomMatching,
subtitle: extension.trackMatching?.strategy != null
? context.l10n.extensionStrategy(extension.trackMatching!.strategy!)
: null,
),
_CapabilityItem(
icon: Icons.auto_fix_high,
title: context.l10n.extensionPostProcessing,
enabled: extension.hasPostProcessing,
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
? context.l10n.extensionHooksAvailable(extension.postProcessing!.hooks.length)
: null,
),
_CapabilityItem(
icon: Icons.link,
title: context.l10n.extensionUrlHandler,
enabled: extension.hasURLHandler,
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
? context.l10n.extensionPatternsCount(extension.urlHandler!.patterns.length)
: null,
showDivider: false,
),
],
),
),
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
child: SettingsSectionHeader(
title: context.l10n.extensionCapabilities,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_URLHandlerInfo(
patterns: extension.urlHandler!.patterns,
_CapabilityItem(
icon: Icons.search,
title: context.l10n.extensionMetadataProvider,
enabled: extension.hasMetadataProvider,
),
_CapabilityItem(
icon: Icons.download,
title: context.l10n.extensionDownloadProvider,
enabled: extension.hasDownloadProvider,
),
_CapabilityItem(
icon: Icons.lyrics,
title: context.l10n.extensionLyricsProvider,
enabled: extension.hasLyricsProvider,
),
_CapabilityItem(
icon: Icons.manage_search,
title: context.l10n.extensionsSearchProvider,
enabled: extension.hasCustomSearch,
subtitle: extension.searchBehavior?.placeholder,
),
_CapabilityItem(
icon: Icons.compare_arrows,
title: context.l10n.extensionCustomTrackMatching,
enabled: extension.hasCustomMatching,
subtitle: extension.trackMatching?.strategy != null
? context.l10n.extensionStrategy(
extension.trackMatching!.strategy!,
)
: null,
),
_CapabilityItem(
icon: Icons.auto_fix_high,
title: context.l10n.extensionPostProcessing,
enabled: extension.hasPostProcessing,
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
? context.l10n.extensionHooksAvailable(
extension.postProcessing!.hooks.length,
)
: null,
),
_CapabilityItem(
icon: Icons.link,
title: context.l10n.extensionUrlHandler,
enabled: extension.hasURLHandler,
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
? context.l10n.extensionPatternsCount(
extension.urlHandler!.patterns.length,
)
: null,
showDivider: false,
),
],
),
),
],
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.qualityOptions.asMap().entries.map((entry) {
final index = entry.key;
final quality = entry.value;
return _QualityOptionItem(
quality: quality,
showDivider: index < extension.qualityOptions.length - 1,
);
}).toList(),
),
),
],
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.postProcessing!.hooks.asMap().entries.map((entry) {
final index = entry.key;
final hook = entry.value;
return _PostProcessingHookItem(
hook: hook,
showDivider: index < extension.postProcessing!.hooks.length - 1,
);
}).toList(),
),
),
],
if (extension.permissions.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.permissions.asMap().entries.map((entry) {
final index = entry.key;
final permission = entry.value;
return _PermissionItem(
permission: permission,
showDivider: index < extension.permissions.length - 1,
);
}).toList(),
),
),
],
if (extension.settings.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
),
if (_isLoadingSettings)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
if (extension.hasURLHandler &&
extension.urlHandler!.patterns.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionUrlHandler,
),
)
else
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.settings.asMap().entries.map((entry) {
children: [
_URLHandlerInfo(patterns: extension.urlHandler!.patterns),
],
),
),
],
if (extension.hasDownloadProvider &&
extension.qualityOptions.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionQualityOptions,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.qualityOptions.asMap().entries.map((
entry,
) {
final index = entry.key;
final setting = entry.value;
return _SettingItem(
setting: setting,
value: _settings[setting.key] ?? setting.defaultValue,
showDivider: index < extension.settings.length - 1,
onChanged: (value) => _updateSetting(setting.key, value),
extensionId: widget.extensionId,
final quality = entry.value;
return _QualityOptionItem(
quality: quality,
showDivider: index < extension.qualityOptions.length - 1,
);
}).toList(),
),
),
],
],
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: OutlinedButton.icon(
onPressed: () => _confirmRemove(context),
icon: const Icon(Icons.delete_outline),
label: Text(context.l10n.extensionRemoveButton),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
if (extension.hasPostProcessing &&
extension.postProcessing!.hooks.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionPostProcessingHooks,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.postProcessing!.hooks.asMap().entries.map(
(entry) {
final index = entry.key;
final hook = entry.value;
return _PostProcessingHookItem(
hook: hook,
showDivider:
index < extension.postProcessing!.hooks.length - 1,
);
},
).toList(),
),
),
],
if (extension.permissions.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionPermissions,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.permissions.asMap().entries.map((entry) {
final index = entry.key;
final permission = entry.value;
return _PermissionItem(
permission: permission,
showDivider: index < extension.permissions.length - 1,
);
}).toList(),
),
),
],
if (extension.settings.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionSettings,
),
),
if (_isLoadingSettings)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
)
else
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.settings.asMap().entries.map((entry) {
final index = entry.key;
final setting = entry.value;
return _SettingItem(
setting: setting,
value: _settings[setting.key] ?? setting.defaultValue,
showDivider: index < extension.settings.length - 1,
onChanged: (value) =>
_updateSetting(setting.key, value),
extensionId: widget.extensionId,
);
}).toList(),
),
),
],
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: OutlinedButton.icon(
onPressed: () => _confirmRemove(context),
icon: const Icon(Icons.delete_outline),
label: Text(context.l10n.extensionRemoveButton),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
),
);
}
@@ -397,9 +451,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogRemoveExtension),
content: Text(
context.l10n.dialogRemoveExtensionMessage,
),
content: Text(context.l10n.dialogRemoveExtensionMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -407,9 +459,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
),
style: FilledButton.styleFrom(backgroundColor: colorScheme.error),
child: Text(context.l10n.dialogRemove),
),
],
@@ -504,10 +554,7 @@ class _CapabilityItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(title, style: Theme.of(context).textTheme.bodyLarge),
if (subtitle != null && enabled) ...[
const SizedBox(height: 2),
Text(
@@ -544,18 +591,15 @@ class _PermissionItem extends StatelessWidget {
final String permission;
final bool showDivider;
const _PermissionItem({
required this.permission,
this.showDivider = true,
});
const _PermissionItem({required this.permission, this.showDivider = true});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
IconData icon = Icons.security;
String description = permission;
if (permission.startsWith('network:')) {
icon = Icons.language;
description = 'Network access to: ${permission.substring(8)}';
@@ -658,7 +702,6 @@ class _SettingItemState extends State<_SettingItem> {
);
}
// For button type, show a different layout
if (widget.setting.type == 'button') {
return Column(
mainAxisSize: MainAxisSize.min,
@@ -674,9 +717,8 @@ class _SettingItemState extends State<_SettingItem> {
if (widget.setting.description != null) ...[
Text(
widget.setting.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
],
@@ -703,7 +745,8 @@ class _SettingItemState extends State<_SettingItem> {
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: widget.setting.type == 'string' || widget.setting.type == 'number'
onTap:
widget.setting.type == 'string' || widget.setting.type == 'number'
? () => _showEditDialog(context)
: null,
child: Padding(
@@ -722,18 +765,17 @@ class _SettingItemState extends State<_SettingItem> {
const SizedBox(height: 2),
Text(
widget.setting.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[
if (widget.setting.type == 'string' ||
widget.setting.type == 'number') ...[
const SizedBox(height: 4),
Text(
widget.value?.toString() ?? 'Not set',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
],
],
@@ -776,23 +818,23 @@ class _SettingItemState extends State<_SettingItem> {
final success = result['success'] as bool? ?? false;
if (!success) {
final error = result['error'] as String? ?? 'Action failed';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
} else {
final message = result['message'] as String?;
if (message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) {
@@ -802,7 +844,9 @@ class _SettingItemState extends State<_SettingItem> {
}
void _showEditDialog(BuildContext context) {
final controller = TextEditingController(text: widget.value?.toString() ?? '');
final controller = TextEditingController(
text: widget.value?.toString() ?? '',
);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
@@ -817,7 +861,9 @@ class _SettingItemState extends State<_SettingItem> {
decoration: InputDecoration(
hintText: widget.setting.description ?? 'Enter value',
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
@@ -849,15 +895,12 @@ class _PostProcessingHookItem extends StatelessWidget {
final PostProcessingHook hook;
final bool showDivider;
const _PostProcessingHookItem({
required this.hook,
this.showDivider = true,
});
const _PostProcessingHookItem({required this.hook, this.showDivider = true});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -904,16 +947,20 @@ class _PostProcessingHookItem extends StatelessWidget {
spacing: 4,
children: hook.supportedFormats.map((format) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
format.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
);
}).toList(),
@@ -924,7 +971,10 @@ class _PostProcessingHookItem extends StatelessWidget {
),
if (hook.defaultEnabled)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
@@ -952,19 +1002,15 @@ class _PostProcessingHookItem extends StatelessWidget {
}
}
class _URLHandlerInfo extends StatelessWidget {
final List<String> patterns;
const _URLHandlerInfo({
required this.patterns,
});
const _URLHandlerInfo({required this.patterns});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -1014,7 +1060,10 @@ class _URLHandlerInfo extends StatelessWidget {
runSpacing: 8,
children: patterns.map((pattern) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
@@ -1022,11 +1071,7 @@ class _URLHandlerInfo extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.language,
size: 16,
color: colorScheme.primary,
),
Icon(Icons.language, size: 16, color: colorScheme.primary),
const SizedBox(width: 6),
Text(
pattern,
@@ -1049,11 +1094,7 @@ class _URLHandlerInfo extends StatelessWidget {
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.primary,
),
Icon(Icons.info_outline, size: 20, color: colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(
@@ -1076,15 +1117,12 @@ class _QualityOptionItem extends StatelessWidget {
final QualityOption quality;
final bool showDivider;
const _QualityOptionItem({
required this.quality,
this.showDivider = true,
});
const _QualityOptionItem({required this.quality, this.showDivider = true});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -1116,7 +1154,8 @@ class _QualityOptionItem extends StatelessWidget {
fontWeight: FontWeight.w500,
),
),
if (quality.description != null && quality.description!.isNotEmpty) ...[
if (quality.description != null &&
quality.description!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
quality.description!,
@@ -1138,7 +1177,10 @@ class _QualityOptionItem extends StatelessWidget {
),
if (quality.settings.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
@@ -72,6 +72,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
+36 -10
View File
@@ -132,7 +132,23 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
// Fallback for older devices
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
if (Platform.isIOS) {
// On iOS, create a security-scoped bookmark so we can access
// this folder across app restarts and from the Go backend.
final bookmark =
await PlatformBridge.createIosBookmarkFromPath(result);
if (bookmark != null && bookmark.isNotEmpty) {
ref
.read(settingsProvider.notifier)
.setLocalLibraryPathAndBookmark(result, bookmark);
} else {
// Bookmark creation failed; save path anyway (works for
// app-internal folders like Documents/).
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
} else {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
}
}
}
@@ -140,6 +156,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
Future<void> _startScan({bool forceFullScan = false}) async {
final settings = ref.read(settingsProvider);
final libraryPath = settings.localLibraryPath;
final iosBookmark = settings.localLibraryBookmark;
if (libraryPath.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -148,7 +165,14 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
if (!libraryPath.startsWith('content://') &&
// On iOS with a bookmark, try resolving the bookmark first to validate
// access instead of checking the path directly (which may fail outside
// the app sandbox).
if (Platform.isIOS && iosBookmark.isNotEmpty) {
// Bookmark will be resolved inside startScan; skip Directory.exists
// check since security-scoped paths are not accessible without the
// bookmark being activated.
} else if (!libraryPath.startsWith('content://') &&
!await Directory(libraryPath).exists()) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -158,9 +182,11 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
await ref
.read(localLibraryProvider.notifier)
.startScan(libraryPath, forceFullScan: forceFullScan);
await ref.read(localLibraryProvider.notifier).startScan(
libraryPath,
forceFullScan: forceFullScan,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
}
Future<void> _cancelScan() async {
@@ -200,9 +226,12 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
}
Future<void> _cleanupMissingFiles() async {
final iosBookmark = ref.read(settingsProvider).localLibraryBookmark;
final removed = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles();
.cleanupMissingFiles(
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -230,6 +259,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
@@ -271,7 +301,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
),
// Scan Settings Section
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.libraryScanSettings,
@@ -442,7 +471,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
],
// Info Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
@@ -558,7 +586,6 @@ class _LibraryHeroCard extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Background decorative elements
Positioned(
right: -20,
top: -20,
@@ -581,7 +608,6 @@ class _LibraryHeroCard extends StatelessWidget {
),
),
// Content
Padding(
padding: const EdgeInsets.all(24),
child: Column(
+156 -95
View File
@@ -6,8 +6,10 @@ import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
final RegExp _domainPattern =
RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false);
final RegExp _domainPattern = RegExp(
r'domain:\s*([^\s,]+)',
caseSensitive: false,
);
class LogScreen extends StatefulWidget {
const LogScreen({super.key});
@@ -17,7 +19,6 @@ class LogScreen extends StatefulWidget {
}
class _LogScreenState extends State<LogScreen> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
String _selectedLevel = 'ALL';
@@ -74,7 +75,9 @@ class _LogScreenState extends State<LogScreen> {
SnackBar(
content: Text(context.l10n.logCopied),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 2),
),
);
@@ -83,7 +86,9 @@ class _LogScreenState extends State<LogScreen> {
void _shareLogs() async {
final logs = await LogBuffer().exportWithDeviceInfo();
SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs'));
SharePlus.instance.share(
ShareParams(text: logs, subject: 'SpotiFLAC Logs'),
);
}
void _clearLogs() {
@@ -137,52 +142,58 @@ class _LogScreenState extends State<LogScreen> {
controller: _scrollController,
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
onPressed: () => setState(() => _autoScroll = !_autoScroll),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
onPressed: _copyLogs,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'share':
_shareLogs();
break;
case 'clear':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'share',
child: ListTile(
leading: const Icon(Icons.share),
title: Text(context.l10n.logShareLogs),
contentPadding: EdgeInsets.zero,
),
actions: [
IconButton(
icon: Icon(
_autoScroll
? Icons.vertical_align_bottom
: Icons.vertical_align_center,
),
PopupMenuItem(
value: 'clear',
child: ListTile(
leading: const Icon(Icons.delete_outline),
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
onPressed: _copyLogs,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
onSelected: (value) {
switch (value) {
case 'share':
_shareLogs();
break;
case 'clear':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'share',
child: ListTile(
leading: const Icon(Icons.share),
title: Text(context.l10n.logShareLogs),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'clear',
child: ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.logClearLogs),
contentPadding: EdgeInsets.zero,
),
@@ -194,11 +205,17 @@ class _LogScreenState extends State<LogScreen> {
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.logTitle,
style: TextStyle(
@@ -213,28 +230,40 @@ class _LogScreenState extends State<LogScreen> {
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
child: SettingsSectionHeader(
title: context.l10n.logFilterSection,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Row(
children: [
Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant),
Icon(
Icons.filter_list,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.logFilterLevel, style: Theme.of(context).textTheme.bodyLarge),
Text(
context.l10n.logFilterLevel,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
context.l10n.logFilterBySeverity,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
@@ -248,8 +277,8 @@ class _LogScreenState extends State<LogScreen> {
child: Text(
level,
style: TextStyle(
color: level == 'ALL'
? colorScheme.onSurface
color: level == 'ALL'
? colorScheme.onSurface
: _getLevelColor(level, colorScheme),
fontWeight: FontWeight.w500,
),
@@ -272,7 +301,10 @@ class _LogScreenState extends State<LogScreen> {
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Row(
children: [
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
@@ -295,6 +327,7 @@ class _LogScreenState extends State<LogScreen> {
fillColor: colorScheme.surfaceContainerHighest,
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
@@ -317,16 +350,16 @@ class _LogScreenState extends State<LogScreen> {
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
? context.l10n.logEntriesFiltered(logs.length)
: context.l10n.logEntries(logs.length),
),
),
SliverToBoxAdapter(
child: _LogSummaryCard(logs: LogBuffer().entries),
),
logs.isEmpty
? SliverToBoxAdapter(
child: SettingsGroup(
@@ -339,21 +372,26 @@ class _LogScreenState extends State<LogScreen> {
Icon(
Icons.article_outlined,
size: 48,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
const SizedBox(height: 16),
Text(
context.l10n.logNoLogsYet,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
context.l10n.logNoLogsYetSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
),
),
],
),
@@ -408,7 +446,7 @@ class _LogEntryTile extends StatelessWidget {
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: isError
color: isError
? colorScheme.errorContainer.withValues(alpha: 0.2)
: null,
),
@@ -427,7 +465,10 @@ class _LogEntryTile extends StatelessWidget {
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: levelColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
@@ -444,7 +485,10 @@ class _LogEntryTile extends StatelessWidget {
if (entry.isFromGo) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.teal.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
@@ -519,9 +563,9 @@ class _LogSummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final analysis = _analyzeLogs();
if (!analysis.hasIssues) {
return const SizedBox.shrink();
}
@@ -530,7 +574,7 @@ class _LogSummaryCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Card(
elevation: 0,
color: analysis.hasISPBlocking
color: analysis.hasISPBlocking
? colorScheme.errorContainer.withValues(alpha: 0.5)
: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -542,9 +586,13 @@ class _LogSummaryCard extends StatelessWidget {
Row(
children: [
Icon(
analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded,
analysis.hasISPBlocking
? Icons.block
: Icons.warning_amber_rounded,
size: 20,
color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary,
color: analysis.hasISPBlocking
? colorScheme.error
: colorScheme.tertiary,
),
const SizedBox(width: 8),
Text(
@@ -557,19 +605,21 @@ class _LogSummaryCard extends StatelessWidget {
],
),
const SizedBox(height: 12),
if (analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.block,
label: 'ISP BLOCKING DETECTED',
description: 'Your ISP may be blocking access to download services',
suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8',
description:
'Your ISP may be blocking access to download services',
suggestion:
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8',
color: colorScheme.error,
domains: analysis.blockedDomains,
),
const SizedBox(height: 8),
],
if (analysis.hasRateLimit) ...[
_IssueBadge(
icon: Icons.speed,
@@ -580,7 +630,7 @@ class _LogSummaryCard extends StatelessWidget {
),
const SizedBox(height: 8),
],
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.wifi_off,
@@ -591,17 +641,19 @@ class _LogSummaryCard extends StatelessWidget {
),
const SizedBox(height: 8),
],
if (analysis.hasNotFound) ...[
_IssueBadge(
icon: Icons.search_off,
label: 'TRACK NOT FOUND',
description: 'Some tracks could not be found on download services',
suggestion: 'The track may not be available in lossless quality',
description:
'Some tracks could not be found on download services',
suggestion:
'The track may not be available in lossless quality',
color: colorScheme.onSurfaceVariant,
),
],
const SizedBox(height: 12),
Text(
'Total errors: ${analysis.errorCount}',
@@ -639,7 +691,7 @@ class _LogSummaryCard extends StatelessWidget {
combined.contains('connection reset') ||
combined.contains('connection refused')) {
hasISPBlocking = true;
final domainMatch = _domainPattern.firstMatch(combined);
if (domainMatch != null) {
blockedDomains.add(domainMatch.group(1)!);
@@ -694,7 +746,12 @@ class _LogAnalysis {
required this.blockedDomains,
});
bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound;
bool get hasIssues =>
errorCount > 0 ||
hasISPBlocking ||
hasRateLimit ||
hasNetworkError ||
hasNotFound;
}
class _IssueBadge extends StatelessWidget {
@@ -717,7 +774,7 @@ class _IssueBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
@@ -746,9 +803,9 @@ class _IssueBadge extends StatelessWidget {
const SizedBox(height: 6),
Text(
description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface),
),
if (domains != null && domains!.isNotEmpty) ...[
const SizedBox(height: 4),
@@ -765,7 +822,11 @@ class _IssueBadge extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary),
Icon(
Icons.lightbulb_outline,
size: 14,
color: colorScheme.primary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
@@ -122,8 +122,6 @@ class _LyricsProviderPriorityPageState
);
}
// ── State mutations ──
void _enableProvider(String id) {
setState(() => _enabledProviders.add(id));
_markChanged();
@@ -142,8 +140,6 @@ class _LyricsProviderPriorityPageState
_markChanged();
}
// ── Save / Discard ──
Future<void> _saveChanges() async {
ref
.read(settingsProvider.notifier)
@@ -180,8 +176,6 @@ class _LyricsProviderPriorityPageState
return result ?? false;
}
// ── Provider metadata ──
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
switch (id) {
case 'spotify_api':
@@ -230,10 +224,6 @@ class _LyricsProviderPriorityPageState
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Enabled provider card (reorderable)
// ═══════════════════════════════════════════════════════════════════════════
class _EnabledProviderItem extends StatelessWidget {
final String providerId;
final _LyricsProviderInfo info;
@@ -273,7 +263,6 @@ class _EnabledProviderItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Numbered badge
Container(
width: 28,
height: 28,
@@ -296,10 +285,8 @@ class _EnabledProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Icon
Icon(info.icon, color: colorScheme.primary),
const SizedBox(width: 12),
// Name + description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -319,7 +306,6 @@ class _EnabledProviderItem extends StatelessWidget {
],
),
),
// Enable/disable switch
SizedBox(
height: 32,
child: FittedBox(
@@ -327,7 +313,6 @@ class _EnabledProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 4),
// Drag handle
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
],
),
@@ -338,10 +323,6 @@ class _EnabledProviderItem extends StatelessWidget {
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Disabled provider card
// ═══════════════════════════════════════════════════════════════════════════
class _DisabledProviderItem extends StatelessWidget {
final String providerId;
final _LyricsProviderInfo info;
@@ -383,10 +364,8 @@ class _DisabledProviderItem extends StatelessWidget {
// Empty space aligned with numbered badge
const SizedBox(width: 28),
const SizedBox(width: 16),
// Icon (muted)
Icon(info.icon, color: colorScheme.outline),
const SizedBox(width: 12),
// Name + description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -407,7 +386,6 @@ class _DisabledProviderItem extends StatelessWidget {
],
),
),
// Switch
SizedBox(
height: 32,
child: FittedBox(
@@ -424,10 +402,6 @@ class _DisabledProviderItem extends StatelessWidget {
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Provider info model
// ═══════════════════════════════════════════════════════════════════════════
class _LyricsProviderInfo {
final String name;
final String description;
@@ -228,13 +228,6 @@ class _MetadataProviderItem extends StatelessWidget {
description: context.l10n.metadataNoRateLimits,
isBuiltIn: true,
);
case 'spotify':
return _MetadataProviderInfo(
name: 'Spotify',
icon: Icons.music_note,
description: context.l10n.metadataMayRateLimit,
isBuiltIn: true,
);
default:
return _MetadataProviderInfo(
name: provider,
+6 -310
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -32,6 +31,7 @@ class OptionsSettingsPage extends ConsumerWidget {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
@@ -43,7 +43,7 @@ class OptionsSettingsPage extends ConsumerWidget {
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
@@ -53,7 +53,7 @@ class OptionsSettingsPage extends ConsumerWidget {
title: Text(
context.l10n.optionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
@@ -72,68 +72,10 @@ class OptionsSettingsPage extends ConsumerWidget {
child: SettingsGroup(
children: [
_MetadataSourceSelector(
currentSource: settings.metadataSource,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setMetadataSource(v),
),
if (settings.metadataSource == 'spotify') ...[
if (settings.spotifyClientId.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Theme.of(
context,
).colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.optionsSpotifyWarning,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onErrorContainer,
fontSize: 12,
),
),
),
],
),
),
),
),
SettingsItem(
icon: Icons.key,
title: context.l10n.optionsSpotifyCredentials,
subtitle: settings.spotifyClientId.isNotEmpty
? context.l10n.optionsSpotifyCredentialsConfigured(
settings.spotifyClientId.length > 8
? settings.spotifyClientId.substring(0, 8)
: settings.spotifyClientId,
)
: context.l10n.optionsSpotifyCredentialsRequired,
onTap: () =>
_showSpotifyCredentialsDialog(context, ref, settings),
trailing: Icon(
settings.spotifyClientId.isNotEmpty
? Icons.check_circle
: Icons.error_outline,
color: settings.spotifyClientId.isNotEmpty
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.error,
size: 20,
),
showDivider: false,
),
],
],
),
),
@@ -331,7 +273,6 @@ class OptionsSettingsPage extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
) async {
// Show loading indicator
showDialog(
context: context,
barrierDismissible: false,
@@ -372,214 +313,6 @@ class OptionsSettingsPage extends ConsumerWidget {
}
}
}
void _showSpotifyCredentialsDialog(
BuildContext context,
WidgetRef ref,
AppSettings settings,
) {
final clientIdController = TextEditingController(
text: settings.spotifyClientId,
);
final clientSecretController = TextEditingController(
text: settings.spotifyClientSecret,
);
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
context.l10n.credentialsTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
context.l10n.credentialsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextField(
controller: clientIdController,
decoration: InputDecoration(
labelText: context.l10n.credentialsClientId,
hintText: context.l10n.credentialsClientIdHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
prefixIcon: const Icon(Icons.person_outline),
),
),
const SizedBox(height: 16),
TextField(
controller: clientSecretController,
obscureText: true,
decoration: InputDecoration(
labelText: context.l10n.credentialsClientSecret,
hintText: context.l10n.credentialsClientSecretHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
prefixIcon: const Icon(Icons.lock_outline),
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: () {
final clientId = clientIdController.text.trim();
final clientSecret = clientSecretController.text.trim();
if (clientId.isNotEmpty && clientSecret.isNotEmpty) {
ref
.read(settingsProvider.notifier)
.setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarCredentialsSaved,
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarFillAllFields),
),
);
}
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
context.l10n.actionSaveCredentials,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (settings.spotifyClientId.isNotEmpty) ...[
const SizedBox(height: 12),
TextButton(
onPressed: () {
ref
.read(settingsProvider.notifier)
.clearSpotifyCredentials();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarCredentialsCleared,
),
),
);
},
style: TextButton.styleFrom(
foregroundColor: colorScheme.error,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(context.l10n.actionRemoveCredentials),
),
],
const SizedBox(height: 16),
],
),
),
),
),
),
);
}
}
class _ConcurrentDownloadsItem extends StatelessWidget {
@@ -875,12 +608,8 @@ class _ChannelChip extends StatelessWidget {
}
class _MetadataSourceSelector extends ConsumerWidget {
final String currentSource;
final ValueChanged<String> onChanged;
const _MetadataSourceSelector({
required this.currentSource,
required this.onChanged,
});
const _MetadataSourceSelector({required this.onChanged});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -930,7 +659,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip(
icon: Icons.graphic_eq,
label: 'Deezer',
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
isSelected: !hasExtensionSearch,
onTap: () {
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
@@ -938,18 +667,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
onChanged('deezer');
},
),
const SizedBox(width: 12),
_SourceChip(
icon: Icons.music_note,
label: 'Spotify',
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
onTap: () {
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
onChanged('spotify');
},
),
],
),
if (hasExtensionSearch) ...[
@@ -964,7 +681,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.optionsSwitchBack,
'Tap Deezer to switch back from extension',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -973,27 +690,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
],
),
],
if (currentSource == 'spotify' && !hasExtensionSearch) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 16,
color: colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.optionsSpotifyDeprecationWarning,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
),
),
],
),
],
],
),
);
@@ -66,6 +66,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () async {
if (_hasChanges) {
@@ -333,12 +334,6 @@ class _ProviderItem extends StatelessWidget {
);
case 'qobuz':
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
case 'amazon':
return _ProviderInfo(
name: 'Amazon Music',
icon: Icons.shopping_bag,
isBuiltIn: true,
);
case 'youtube':
return _ProviderInfo(
name: 'YouTube',
+5 -6
View File
@@ -22,7 +22,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final PageController _pageController = PageController();
int _currentStep = 0;
// State variables
bool _storagePermissionGranted = false;
bool _notificationPermissionGranted = false;
String? _selectedDirectory;
@@ -474,7 +473,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Calculate progress
final progress = (_currentStep + 1) / _totalSteps;
return Scaffold(
@@ -482,7 +480,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
body: SafeArea(
child: Column(
children: [
// Top Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
@@ -490,6 +487,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (_currentStep > 0)
IconButton.filledTonal(
onPressed: _prevPage,
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -497,9 +497,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
)
else
const SizedBox(width: 48), // Spacer
const SizedBox(width: 48),
const Spacer(),
// Progress Indicator
SizedBox(
width: 48,
height: 48,
@@ -530,7 +529,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
),
// Content
Expanded(
child: PageView(
controller: _pageController,
@@ -713,6 +711,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
tooltip: 'Change folder',
icon: const Icon(Icons.edit),
onPressed: _selectDirectory,
),
+12 -10
View File
@@ -17,7 +17,6 @@ class ExtensionDetailsScreen extends ConsumerStatefulWidget {
class _ExtensionDetailsScreenState
extends ConsumerState<ExtensionDetailsScreen> {
@override
Widget build(BuildContext context) {
final storeState = ref.watch(storeProvider);
@@ -116,6 +115,7 @@ class _ExtensionDetailsScreenState
),
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
@@ -171,7 +171,7 @@ class _ExtensionDetailsScreenState
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
const SizedBox(height: 4),
Text(
context.l10n.extensionsAuthor(ext.author),
style: Theme.of(context).textTheme.bodyLarge
@@ -222,7 +222,9 @@ class _ExtensionDetailsScreenState
FilledButton.icon(
onPressed: () => _updateExtension(ext),
icon: const Icon(Icons.update),
label: Text('${context.l10n.storeUpdate} v${ext.version}'),
label: Text(
'${context.l10n.storeUpdate} v${ext.version}',
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
@@ -405,7 +407,8 @@ class _ExtensionDetailsScreenState
StoreExtension ext,
ColorScheme colorScheme,
) {
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
final isMetadataProvider =
ext.category == 'metadata' || ext.category == 'integration';
final isDownloadProvider = ext.category == 'download';
final isLyricsProvider = ext.category == 'lyrics';
final isUtility = ext.category == 'utility';
@@ -458,7 +461,7 @@ class _ExtensionDetailsScreenState
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) {
return context.l10n.dateToday;
} else if (diff.inDays == 1) {
@@ -560,7 +563,9 @@ class _ExtensionDetailsScreenState
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogUninstallExtension),
content: Text(context.l10n.dialogUninstallExtensionMessage(ext.displayName)),
content: Text(
context.l10n.dialogUninstallExtensionMessage(ext.displayName),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -718,10 +723,7 @@ class _CapabilityRow extends StatelessWidget {
Expanded(
child: Text(
label,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
),
style: TextStyle(color: colorScheme.onSurface, fontSize: 14),
),
),
Icon(
+1
View File
@@ -122,6 +122,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
+685 -64
View File
@@ -13,11 +13,14 @@ import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
final _log = AppLogger('TrackMetadata');
@@ -66,6 +69,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _isInstrumental = false; // Track if detected as instrumental
bool _isConverting = false; // Track convert operation in progress
bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false;
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController();
@@ -213,10 +217,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _checkFile() async {
var filePath = _filePath;
if (filePath.startsWith('EXISTS:')) {
filePath = filePath.substring(7);
}
final filePath = cleanFilePath;
bool exists = false;
int? size;
@@ -240,6 +241,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (mounted && exists && _lyrics == null && !_lyricsLoading) {
_fetchLyrics();
}
if (mounted &&
exists &&
!_isLocalItem &&
!_hasLoadedResolvedAudioMetadata) {
unawaited(_refreshResolvedAudioMetadataFromFile());
}
if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) {
final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid(
_coverCacheKey,
@@ -274,6 +281,61 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
await _cleanupTempFileAndParent(path);
}
Future<void> _refreshResolvedAudioMetadataFromFile() async {
if (_isLocalItem ||
_downloadItem == null ||
_hasLoadedResolvedAudioMetadata) {
return;
}
_hasLoadedResolvedAudioMetadata = true;
try {
final metadata = await PlatformBridge.readFileMetadata(cleanFilePath);
if (metadata['error'] != null) {
return;
}
final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']);
final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']);
final resolvedQuality = buildDisplayAudioQuality(
bitDepth: resolvedBitDepth ?? bitDepth,
sampleRate: resolvedSampleRate ?? sampleRate,
storedQuality: _quality,
);
final shouldPersistResolvedAudioMetadata =
resolvedBitDepth != null ||
resolvedSampleRate != null ||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null);
if ((resolvedBitDepth != null ||
resolvedSampleRate != null ||
isPlaceholderQualityLabel(_quality)) &&
mounted) {
setState(() {
_editedMetadata = {
...?_editedMetadata,
if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth,
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
};
});
}
if (shouldPersistResolvedAudioMetadata) {
await ref
.read(downloadHistoryProvider.notifier)
.updateAudioMetadataForItem(
id: _downloadItem!.id,
quality: resolvedQuality,
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
);
}
} catch (e) {
_log.w('Failed to resolve audio metadata from file: $e');
}
}
void _cleanupTempFileAndParentSync(String? path) {
if (!_hasPath(path)) return;
final file = File(path!);
@@ -426,9 +488,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
int? get duration =>
_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
int? get bitDepth =>
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
_readPositiveInt(_editedMetadata?['bit_depth']) ??
(_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth);
int? get sampleRate =>
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
_readPositiveInt(_editedMetadata?['sample_rate']) ??
(_isLocalItem
? _localLibraryItem!.sampleRate
: _downloadItem!.sampleRate);
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
String get _filePath =>
@@ -452,11 +518,91 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
String get cleanFilePath {
int? _readPositiveInt(dynamic value) {
if (value == null) return null;
if (value is num) {
final asInt = value.toInt();
return asInt > 0 ? asInt : null;
}
final parsed = int.tryParse(value.toString());
if (parsed == null || parsed <= 0) return null;
return parsed;
}
String? get _displayAudioQuality {
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExt = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: null;
return buildDisplayAudioQuality(
bitDepth: bitDepth,
sampleRate: sampleRate,
bitrateKbps: _isLocalItem ? _localBitrate : null,
format: _isLocalItem ? (_localLibraryItem!.format ?? fileExt) : fileExt,
storedQuality: _quality,
);
}
/// The raw file path, with EXISTS: prefix stripped but #trackNN preserved.
/// Use this when you need the full virtual path (e.g. for display or DB lookups).
String get rawFilePath {
final path = _filePath;
return path.startsWith('EXISTS:') ? path.substring(7) : path;
}
/// The clean file path with both EXISTS: prefix and #trackNN suffix stripped.
/// Use this for actual filesystem/SAF operations.
String get cleanFilePath {
var path = _filePath;
if (path.startsWith('EXISTS:')) path = path.substring(7);
// Strip CUE virtual path suffix for filesystem operations
if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path);
return path;
}
bool get _isCueVirtualTrack => isCueVirtualPath(rawFilePath);
String _cueVirtualTrackGuidance(BuildContext context) {
return 'This CUE track is virtual. Use ${context.l10n.cueSplitButton} first.';
}
void _showCueVirtualTrackSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_cueVirtualTrackGuidance(context))),
);
}
void _hideCurrentSnackBar() {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
}
String get _l10nCueSplitFailed => context.l10n.cueSplitFailed;
String get _l10nCueSplitNoAudioFile => context.l10n.cueSplitNoAudioFile;
String _l10nCueSplitSplitting(int current, int total) {
return context.l10n.cueSplitSplitting(current, total);
}
String _l10nCueSplitSuccess(int count) {
return context.l10n.cueSplitSuccess(count);
}
void _showSnackBarMessage(String 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),
),
);
}
String _formatPathForDisplay(String pathOrUri) {
if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) {
return pathOrUri;
@@ -558,6 +704,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -570,6 +717,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
actions: [
IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -627,7 +775,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (_hasPath(_embeddedCoverPreviewPath))
Image.file(
File(_embeddedCoverPreviewPath!),
@@ -657,7 +804,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -676,7 +822,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
),
// Track info overlay at bottom
Positioned(
left: 20,
right: 20,
@@ -726,7 +871,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
spacing: 8,
runSpacing: 8,
children: [
if (_quality != null && _quality!.isNotEmpty)
if (_displayAudioQuality != null &&
_displayAudioQuality!.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
@@ -737,7 +883,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
borderRadius: BorderRadius.circular(20),
),
child: Text(
_quality!,
_displayAudioQuality!,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -964,34 +1110,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string - prefer stored quality from download
String? audioQualityStr;
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExt = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: '';
// Use stored quality from download history if available
if (_quality != null && _quality!.isNotEmpty) {
audioQualityStr = _quality;
} else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) {
// Lossy local file with bitrate info
final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt;
audioQualityStr = '$fmt ${_localBitrate}kbps';
} else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) {
// Lossless file with actual bit depth (FLAC, ALAC)
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
} else {
// Fallback based on file extension for legacy items
if (fileExt == 'MP3') {
audioQualityStr = 'MP3';
} else if (fileExt == 'OPUS' || fileExt == 'OGG') {
audioQualityStr = 'Opus';
} else if (fileExt == 'M4A' || fileExt == 'AAC') {
audioQualityStr = 'AAC';
}
}
final audioQualityStr = _displayAudioQuality;
final items = <_MetadataItem>[
_MetadataItem(context.l10n.trackTrackName, trackName),
@@ -1088,12 +1207,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool fileExists,
int? fileSize,
) {
final displayFilePath = _formatPathForDisplay(cleanFilePath);
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final displayFilePath = _formatPathForDisplay(rawFilePath);
final fileName = _extractFileNameFromPathOrUri(rawFilePath);
final fileExtension = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: 'Unknown';
final lossyBitrateLabel = _extractLossyBitrateLabel(_quality);
final resolvedQuality = _displayAudioQuality;
final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality);
return Card(
elevation: 0,
@@ -1223,7 +1343,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
borderRadius: BorderRadius.circular(20),
),
child: Text(
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
buildDisplayAudioQuality(
bitDepth: bitDepth,
sampleRate: sampleRate,
) ??
'',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
@@ -2295,7 +2419,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
flex: 2,
child: FilledButton.icon(
onPressed: fileExists
? () => _openFile(context, cleanFilePath)
? () => _openFile(context, rawFilePath)
: null,
icon: const Icon(Icons.play_arrow),
label: Text(context.l10n.trackMetadataPlay),
@@ -2417,6 +2541,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_showConvertSheet(context);
},
),
if (_fileExists && _isCueFile)
ListTile(
leading: const Icon(Icons.call_split),
title: Text(context.l10n.cueSplitTitle),
subtitle: Text(context.l10n.cueSplitSubtitle),
onTap: () {
Navigator.pop(context);
_showCueSplitSheet(context);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.share),
@@ -2454,11 +2588,34 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
lower.endsWith('.ogg');
}
/// Whether the current file is a CUE sheet (or CUE-referenced)
bool get _isCueFile {
// Check if the raw path has a CUE virtual path suffix
if (isCueVirtualPath(rawFilePath)) return true;
final lower = cleanFilePath.toLowerCase();
if (lower.endsWith('.cue')) return true;
// Check if local library item has cue+ format
if (_isLocalItem && _localLibraryItem != null) {
final format = _localLibraryItem!.format ?? '';
if (format.startsWith('cue+')) return true;
}
return false;
}
String get _currentFileFormat {
// For CUE tracks, use the format from the library item (e.g. "cue+flac")
if (_isCueFile && _isLocalItem && _localLibraryItem != null) {
final format = _localLibraryItem!.format ?? '';
if (format.startsWith('cue+')) {
final audioFmt = format.substring(4).toUpperCase();
return 'CUE+$audioFmt';
}
}
final lower = cleanFilePath.toLowerCase();
if (lower.endsWith('.flac')) return 'FLAC';
if (lower.endsWith('.mp3')) return 'MP3';
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus';
if (lower.endsWith('.cue')) return 'CUE';
return 'Unknown';
}
@@ -2606,7 +2763,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 20),
// Target format
Text(
context.l10n.trackConvertTargetFormat,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -2639,7 +2795,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 16),
// Bitrate
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -2664,7 +2819,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 24),
// Convert button
SizedBox(
width: double.infinity,
child: FilledButton(
@@ -2699,6 +2853,470 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
void _showCueSplitSheet(BuildContext context) async {
// Strip the #trackNN suffix from virtual CUE paths to get the real .cue path
var cuePath = cleanFilePath;
final trackSuffix = RegExp(r'#track\d+$');
if (trackSuffix.hasMatch(cuePath)) {
cuePath = cuePath.replaceFirst(trackSuffix, '');
}
// Show loading indicator
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Loading CUE sheet...')),
);
try {
final cueInfo = await PlatformBridge.parseCueSheet(cuePath);
if (!mounted) return;
_hideCurrentSnackBar();
if (cueInfo.containsKey('error')) {
_showSnackBarMessage(_l10nCueSplitNoAudioFile);
return;
}
final album = cueInfo['album'] as String? ?? 'Unknown Album';
final artist = cueInfo['artist'] as String? ?? 'Unknown Artist';
final audioPath = cueInfo['audio_path'] as String? ?? '';
final genre = cueInfo['genre'] as String? ?? '';
final date = cueInfo['date'] as String? ?? '';
final tracksRaw = cueInfo['tracks'] as List<dynamic>? ?? [];
if (audioPath.isEmpty) {
_showSnackBarMessage(_l10nCueSplitNoAudioFile);
return;
}
final tracks = tracksRaw
.map((t) => CueSplitTrackInfo.fromJson(t as Map<String, dynamic>))
.toList();
if (tracks.isEmpty) {
_showSnackBarMessage(_l10nCueSplitFailed);
return;
}
if (!mounted) return;
showModalBottomSheet(
context: this.context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
final colorScheme = Theme.of(sheetContext).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
sheetContext.l10n.cueSplitTitle,
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,
),
),
const SizedBox(height: 4),
Text(
sheetContext.l10n.cueSplitArtist(artist),
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,
),
),
const SizedBox(height: 16),
// Track list preview (scrollable, max 200px)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
final duration = track.endSec > 0
? track.endSec - track.startSec
: 0.0;
final durationStr = duration > 0
? '${(duration ~/ 60).toString().padLeft(2, '0')}:${(duration.toInt() % 60).toString().padLeft(2, '0')}'
: '';
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 14,
backgroundColor: colorScheme.primaryContainer,
child: Text(
'${track.number}',
style: TextStyle(
fontSize: 11,
color: colorScheme.onPrimaryContainer,
),
),
),
title: Text(
track.title,
style: const TextStyle(fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: track.artist.isNotEmpty
? Text(
track.artist,
style: TextStyle(
fontSize: 11,
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: durationStr.isNotEmpty
? Text(
durationStr,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
)
: null,
);
},
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
Navigator.pop(sheetContext);
_confirmAndSplitCue(
context: this.context,
audioPath: audioPath,
album: album,
artist: artist,
genre: genre,
date: date,
tracks: tracks,
);
},
icon: const Icon(Icons.call_split),
label: Text(sheetContext.l10n.cueSplitButton),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 8),
],
),
),
);
},
);
} catch (e) {
if (!mounted) return;
_hideCurrentSnackBar();
_showSnackBarMessage(_l10nCueSplitFailed);
_log.e('Failed to parse CUE sheet: $e');
}
}
void _confirmAndSplitCue({
required BuildContext context,
required String audioPath,
required String album,
required String artist,
required String genre,
required String date,
required List<CueSplitTrackInfo> tracks,
}) {
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(dialogContext.l10n.cueSplitConfirmTitle),
content: Text(
dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext);
_performCueSplit(
audioPath: audioPath,
album: album,
artist: artist,
genre: genre,
date: date,
tracks: tracks,
);
},
child: Text(dialogContext.l10n.cueSplitButton),
),
],
);
},
);
}
Future<Directory> _resolvePersistentCueSplitOutputDir() async {
final settings = ref.read(settingsProvider);
final queueState = ref.read(downloadQueueProvider);
final configuredOutputDir = queueState.outputDir.trim();
if (settings.storageMode != 'saf' &&
configuredOutputDir.isNotEmpty &&
!isContentUri(configuredOutputDir)) {
final dir = Directory(configuredOutputDir);
await dir.create(recursive: true);
return dir;
}
if (Platform.isAndroid) {
final externalDir = await getExternalStorageDirectory();
if (externalDir != null) {
final musicDir = Directory(
'${externalDir.parent.parent.parent.parent.path}'
'${Platform.pathSeparator}Music'
'${Platform.pathSeparator}SpotiFLAC',
);
await musicDir.create(recursive: true);
return musicDir;
}
}
final docsDir = await getApplicationDocumentsDirectory();
final fallbackDir = Directory(
'${docsDir.path}${Platform.pathSeparator}SpotiFLAC',
);
await fallbackDir.create(recursive: true);
return fallbackDir;
}
Future<List<String>?> _exportCueSplitOutputsToSaf({
required List<String> outputPaths,
required String treeUri,
required String relativeDir,
}) async {
final exportedUris = <String>[];
for (final path in outputPaths) {
final fileName = path.split(Platform.pathSeparator).last;
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: fileName,
mimeType: audioMimeTypeForPath(path),
srcPath: path,
);
if (safUri != null && safUri.isNotEmpty) {
exportedUris.add(safUri);
}
}
return exportedUris.isEmpty ? null : exportedUris;
}
Future<void> _performCueSplit({
required String audioPath,
required String album,
required String artist,
required String genre,
required String date,
required List<CueSplitTrackInfo> tracks,
}) async {
if (_isConverting) return;
setState(() => _isConverting = true);
String? safTempAudioPath;
Directory? tempSplitDir;
try {
// For SAF content:// audio paths, copy to temp for FFmpeg processing
String workingAudioPath = audioPath;
final isSafSource = isContentUri(audioPath);
if (isSafSource) {
final tempPath = await PlatformBridge.copyContentUriToTemp(audioPath);
if (tempPath == null || tempPath.isEmpty) {
throw Exception('Failed to copy SAF audio file to temp');
}
safTempAudioPath = tempPath;
workingAudioPath = tempPath;
}
// Determine output directory
final String outputDir;
final treeUri = !_isLocalItem ? (_downloadItem?.downloadTreeUri ?? '') : '';
final relativeDir = !_isLocalItem ? (_downloadItem?.safRelativeDir ?? '') : '';
final writeBackToSaf = isSafSource && treeUri.isNotEmpty;
if (writeBackToSaf) {
final tempDir = await getTemporaryDirectory();
tempSplitDir = Directory(
'${tempDir.path}${Platform.pathSeparator}'
'cue_split_${DateTime.now().millisecondsSinceEpoch}',
);
await tempSplitDir.create(recursive: true);
outputDir = tempSplitDir.path;
} else if (isSafSource) {
final persistentDir = await _resolvePersistentCueSplitOutputDir();
outputDir = persistentDir.path;
} else {
outputDir = File(audioPath).parent.path;
}
if (!mounted) return;
_showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length));
// Extract cover from audio file for embedding
String? coverPath;
try {
final tempDir = await getTemporaryDirectory();
final coverOutput =
'${tempDir.path}${Platform.pathSeparator}cue_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
final coverResult = await PlatformBridge.extractCoverToFile(
workingAudioPath,
coverOutput,
);
if (coverResult['error'] == null) {
coverPath = coverOutput;
}
} catch (_) {}
final albumMetadata = <String, String>{
'artist': artist,
'album': album,
'genre': genre,
'date': date,
};
final outputPaths = await FFmpegService.splitCueToTracks(
audioPath: workingAudioPath,
outputDir: outputDir,
tracks: tracks,
albumMetadata: albumMetadata,
coverPath: coverPath,
onProgress: (current, total) {
if (mounted) {
_hideCurrentSnackBar();
_showLongSnackBarMessage(_l10nCueSplitSplitting(current, total));
}
},
);
var finalOutputPaths = outputPaths;
// Embed cover art into split FLAC files using Go backend
if (coverPath != null && finalOutputPaths != null) {
for (final path in finalOutputPaths) {
if (path.toLowerCase().endsWith('.flac')) {
try {
// Read existing metadata first
final metadata = await PlatformBridge.readFileMetadata(path);
if (metadata['error'] == null) {
final fields = <String, String>{
'cover_path': coverPath,
};
// Preserve existing fields
for (final entry in metadata.entries) {
if (entry.key == 'error' || entry.value == null) continue;
final v = entry.value.toString().trim();
if (v.isNotEmpty) {
fields[entry.key] = v;
}
}
await PlatformBridge.editFileMetadata(path, fields);
}
} catch (e) {
_log.w('Failed to embed cover to split track: $e');
}
}
}
}
if (writeBackToSaf && finalOutputPaths != null) {
final exportedUris = await _exportCueSplitOutputsToSaf(
outputPaths: finalOutputPaths,
treeUri: treeUri,
relativeDir: relativeDir,
);
finalOutputPaths = exportedUris;
}
// Cleanup cover temp
if (coverPath != null) {
try {
await File(coverPath).delete();
} catch (_) {}
}
if (mounted) {
_hideCurrentSnackBar();
if (finalOutputPaths != null && finalOutputPaths.isNotEmpty) {
_showSnackBarMessage(_l10nCueSplitSuccess(finalOutputPaths.length));
} else {
_showSnackBarMessage(_l10nCueSplitFailed);
}
}
} catch (e) {
_log.e('CUE split failed: $e');
if (mounted) {
_hideCurrentSnackBar();
_showSnackBarMessage(_l10nCueSplitFailed);
}
} finally {
// Cleanup SAF temp audio copy
if (safTempAudioPath != null) {
try {
await File(safTempAudioPath).delete();
} catch (_) {}
}
if (tempSplitDir != null) {
try {
await tempSplitDir.delete(recursive: true);
} catch (_) {}
}
if (mounted) {
setState(() => _isConverting = false);
}
}
}
void _confirmAndConvert({
required BuildContext context,
required String sourceFormat,
@@ -2750,7 +3368,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
SnackBar(content: Text(context.l10n.trackConvertConverting)),
);
// Step 1: Read metadata from file (fallback to known item metadata).
final metadata = _buildFallbackMetadata();
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
@@ -2768,7 +3385,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_log.w('readFileMetadata threw, using fallback metadata: $e');
}
// Step 2: Extract cover art to temp file
String? coverPath;
try {
final tempDir = await getTemporaryDirectory();
@@ -2783,7 +3399,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
} catch (_) {}
// Step 3: Handle SAF vs regular file
String workingPath = cleanFilePath;
final isSaf = _isSafFile;
String? safTempPath;
@@ -2803,7 +3418,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
workingPath = safTempPath;
}
// Step 4: Convert
final newPath = await FFmpegService.convertAudioFormat(
inputPath: workingPath,
targetFormat: targetFormat.toLowerCase(),
@@ -2838,7 +3452,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate);
// Step 5: Handle SAF write-back
if (isSaf) {
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
@@ -3055,16 +3668,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
TextButton(
onPressed: () async {
if (_isLocalItem) {
// For local items, just delete the file
try {
await deleteFile(cleanFilePath);
} catch (e) {
debugPrint('Failed to delete file: $e');
if (_isCueVirtualTrack && _localLibraryItem != null) {
await ref
.read(localLibraryProvider.notifier)
.removeItem(_localLibraryItem!.id);
} else {
try {
await deleteFile(cleanFilePath);
} catch (e) {
debugPrint('Failed to delete file: $e');
}
}
// Also remove from local library database
// ref.read(localLibraryProvider.notifier).removeItem(_localLibraryItem!.id);
} else {
// Existing download history deletion logic
try {
await deleteFile(cleanFilePath);
} catch (e) {
@@ -3092,6 +3707,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _openFile(BuildContext context, String filePath) async {
if (isCueVirtualPath(filePath)) {
_showCueVirtualTrackSnackBar(context);
return;
}
try {
await ref
.read(playbackProvider.notifier)
@@ -3124,6 +3743,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _shareFile(BuildContext context) async {
if (_isCueVirtualTrack) {
_showCueVirtualTrackSnackBar(context);
return;
}
String sharePath = cleanFilePath;
if (!await fileExists(sharePath)) {
if (context.mounted) {
@@ -3661,7 +4285,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
expand: false,
builder: (context, scrollController) => Column(
children: [
// Handle bar
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
@@ -3673,7 +4296,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
),
// Title row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
@@ -3698,7 +4320,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
const SizedBox(height: 12),
// Fields
Expanded(
child: ListView(
controller: scrollController,
+48 -42
View File
@@ -90,7 +90,6 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
body: SafeArea(
child: Column(
children: [
// Top Navigation Bar
Padding(
padding: EdgeInsets.symmetric(
horizontal: topBarPaddingH,
@@ -104,6 +103,9 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
opacity: _currentPage > 0 ? 1.0 : 0.0,
child: IconButton.filledTonal(
onPressed: _currentPage > 0 ? _prevPage : null,
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -112,7 +114,6 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
),
),
// Skip button
TextButton(
onPressed: _skipTutorial,
style: TextButton.styleFrom(
@@ -131,7 +132,6 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
),
),
// Main Content Area
Expanded(
child: PageView(
controller: _pageController,
@@ -218,12 +218,10 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
),
),
// Bottom Control Area
Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Expressive Page Indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_totalPages, (index) {
@@ -246,7 +244,6 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
}),
),
SizedBox(height: bottomGap),
// Action Button
SizedBox(
width: double.infinity,
height: actionButtonHeight,
@@ -402,7 +399,6 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search Input
TextField(
controller: _controller,
onChanged: (value) {
@@ -428,7 +424,6 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> {
),
),
// Result Placeholder
AnimatedSize(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutBack,
@@ -541,7 +536,6 @@ class _InteractiveDownloadExampleState
_isCompleted = true;
});
// Reset after a delay
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() {
@@ -622,40 +616,52 @@ class _InteractiveDownloadExampleState
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.all(buttonPadding),
decoration: BoxDecoration(
color: _isCompleted ? Colors.green : colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color:
(_isCompleted ? Colors.green : colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: _isDownloading
? SizedBox(
width: buttonIconSize,
height: buttonIconSize,
child: CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.onPrimary,
),
)
: Icon(
_isCompleted
? Icons.check_rounded
: Icons.download_rounded,
color: colorScheme.onPrimary,
size: buttonIconSize,
Semantics(
button: true,
label: _isCompleted
? 'Download completed'
: _isDownloading
? 'Download in progress'
: 'Start download',
child: GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.all(buttonPadding),
decoration: BoxDecoration(
color: _isCompleted ? Colors.green : colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color:
(_isCompleted
? Colors.green
: colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: _isDownloading
? SizedBox(
width: buttonIconSize,
height: buttonIconSize,
child: CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.onPrimary,
),
)
: ExcludeSemantics(
child: Icon(
_isCompleted
? Icons.check_rounded
: Icons.download_rounded,
color: colorScheme.onPrimary,
size: buttonIconSize,
),
),
),
),
),
],
+160 -6
View File
@@ -198,7 +198,7 @@ class FFmpegService {
final trimmedKey = decryptionKey.trim();
if (trimmedKey.isEmpty) return inputPath;
// Amazon encrypted streams are commonly MP4 container with FLAC audio.
// Encrypted streams are commonly MP4 container with FLAC audio.
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
? '.flac'
@@ -217,7 +217,10 @@ class FFmpegService {
required String key,
}) {
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y';
// Force MOV demuxer: -decryption_key is only supported by the MOV/MP4
// demuxer. The input may carry a .flac extension (SAF mode) while actually
// containing an encrypted M4A stream, so we must override auto-detection.
return '-v error -decryption_key "$key" -f mov -i "$inputPath" $audioMap-c copy "$outputPath" -y';
}
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
@@ -627,7 +630,7 @@ class FFmpegService {
return null;
}
static Future<LiveDecryptedStreamResult?> startAmazonLiveDecryptedStream({
static Future<LiveDecryptedStreamResult?> startEncryptedLiveDecryptedStream({
required String encryptedStreamUrl,
required String decryptionKey,
String preferredFormat = 'flac',
@@ -1225,7 +1228,6 @@ class FFmpegService {
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
// Step 1: Convert audio
String command;
if (format == 'opus') {
command =
@@ -1245,7 +1247,6 @@ class FFmpegService {
return null;
}
// Step 2: Embed metadata + cover into the converted file.
// Treat embed failure as conversion failure when metadata/cover was requested.
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
@@ -1281,7 +1282,6 @@ class FFmpegService {
}
}
// Step 3: Delete original if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
@@ -1353,6 +1353,160 @@ class FFmpegService {
return id3Map;
}
/// Split a CUE+audio file into individual track files using FFmpeg.
/// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded.
/// [audioPath] is the source audio file (FLAC, WAV, etc.)
/// [outputDir] is where individual track files will be saved
/// [tracks] is the list of track split info from the Go CUE parser
/// [albumMetadata] contains album-level metadata (artist, album, genre, date)
/// Returns list of output file paths on success, null on failure.
static Future<List<String>?> splitCueToTracks({
required String audioPath,
required String outputDir,
required List<CueSplitTrackInfo> tracks,
required Map<String, String> albumMetadata,
String? coverPath,
void Function(int current, int total)? onProgress,
}) async {
if (tracks.isEmpty) {
_log.e('No tracks to split');
return null;
}
final outputPaths = <String>[];
final inputExt = audioPath.toLowerCase().split('.').last;
// For lossless formats, keep as FLAC; for others, keep original format
final outputExt = (inputExt == 'flac' || inputExt == 'wav' || inputExt == 'ape' || inputExt == 'wv')
? 'flac'
: inputExt;
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
onProgress?.call(i + 1, tracks.length);
// Sanitize filename
final sanitizedTitle = track.title
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
final trackNumStr = track.number.toString().padLeft(2, '0');
final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt';
final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName';
// Build FFmpeg command for this track
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$audioPath" ');
// Time range
final startTime = _formatSecondsForFFmpeg(track.startSec);
cmdBuffer.write('-ss $startTime ');
if (track.endSec > 0) {
final endTime = _formatSecondsForFFmpeg(track.endSec);
cmdBuffer.write('-to $endTime ');
}
if (outputExt == 'flac') {
cmdBuffer.write('-c:a flac -compression_level 8 ');
} else {
cmdBuffer.write('-c:a copy ');
}
// Metadata
final artist = track.artist.isNotEmpty ? track.artist : (albumMetadata['artist'] ?? '');
final album = albumMetadata['album'] ?? '';
final genre = albumMetadata['genre'] ?? '';
final date = albumMetadata['date'] ?? '';
void addMeta(String key, String value) {
if (value.isNotEmpty) {
final sanitized = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitized" ');
}
}
addMeta('TITLE', track.title);
addMeta('ARTIST', artist);
addMeta('ALBUM', album);
addMeta('ALBUMARTIST', albumMetadata['artist'] ?? '');
addMeta('TRACKNUMBER', track.number.toString());
addMeta('GENRE', genre);
addMeta('DATE', date);
if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc);
if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer);
cmdBuffer.write('"$outputPath" -y');
final command = cmdBuffer.toString();
_log.d('CUE split track ${track.number}: ${_previewCommandForLog(command)}');
final result = await _execute(command);
if (!result.success) {
_log.e('CUE split failed for track ${track.number}: ${result.output}');
// Continue with remaining tracks instead of failing completely
continue;
}
// Embed cover art if available (for FLAC output)
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {
// Use the Go backend for FLAC cover embedding via PlatformBridge
// (handled by the caller)
}
outputPaths.add(outputPath);
_log.i('CUE split: track ${track.number} -> $outputFileName');
}
if (outputPaths.isEmpty) {
_log.e('CUE split: no tracks were successfully extracted');
return null;
}
_log.i('CUE split complete: ${outputPaths.length}/${tracks.length} tracks');
return outputPaths;
}
static String _formatSecondsForFFmpeg(double seconds) {
if (seconds < 0) return '0';
final hours = seconds ~/ 3600;
final mins = (seconds % 3600) ~/ 60;
final secs = seconds - (hours * 3600) - (mins * 60);
return '${hours.toString().padLeft(2, '0')}:${mins.toInt().toString().padLeft(2, '0')}:${secs.toStringAsFixed(3).padLeft(6, '0')}';
}
}
/// Track info for CUE splitting, passed from the CUE parser
class CueSplitTrackInfo {
final int number;
final String title;
final String artist;
final String isrc;
final String composer;
final double startSec;
final double endSec;
CueSplitTrackInfo({
required this.number,
required this.title,
required this.artist,
this.isrc = '',
this.composer = '',
required this.startSec,
required this.endSec,
});
factory CueSplitTrackInfo.fromJson(Map<String, dynamic> json) {
return CueSplitTrackInfo(
number: json['number'] as int? ?? 0,
title: json['title'] as String? ?? '',
artist: json['artist'] as String? ?? '',
isrc: json['isrc'] as String? ?? '',
composer: json['composer'] as String? ?? '',
startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0,
endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0,
);
}
}
class FFmpegResult {
+23 -4
View File
@@ -104,8 +104,6 @@ class HistoryDatabase {
}
}
// ==================== iOS Path Normalization ====================
/// Pattern to match iOS container paths
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
static final _iosContainerPattern = RegExp(
@@ -325,8 +323,6 @@ class HistoryDatabase {
};
}
// ==================== CRUD Operations ====================
/// Insert or update a history item
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
@@ -502,6 +498,29 @@ class HistoryDatabase {
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
}
Future<void> updateAudioMetadata(
String id, {
String? newQuality,
int? newBitDepth,
int? newSampleRate,
}) async {
final db = await database;
final values = <String, dynamic>{};
if (newQuality != null) {
values['quality'] = newQuality;
}
if (newBitDepth != null) {
values['bit_depth'] = newBitDepth;
}
if (newSampleRate != null) {
values['sample_rate'] = newSampleRate;
}
if (values.isEmpty) {
return;
}
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
}
/// Get all file paths from download history
/// Used to exclude downloaded files from local library scan
Future<Set<String>> getAllFilePaths() async {
+56 -65
View File
@@ -20,51 +20,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
_log.d('getSpotifyMetadata: $url');
final result = await _channel.invokeMethod('getSpotifyMetadata', {
'url': url,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchSpotify(
String query, {
int limit = 10,
}) async {
_log.d('searchSpotify: "$query" (limit: $limit)');
final result = await _channel.invokeMethod('searchSpotify', {
'query': query,
'limit': limit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchSpotifyAll(
String query, {
int trackLimit = 15,
int artistLimit = 3,
}) async {
_log.d('searchSpotifyAll: "$query"');
final result = await _channel.invokeMethod('searchSpotifyAll', {
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getSpotifyRelatedArtists(
String artistId, {
int limit = 12,
}) async {
final result = await _channel.invokeMethod('getSpotifyRelatedArtists', {
'artist_id': artistId,
'limit': limit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> checkAvailability(
String spotifyId,
String isrc,
@@ -403,8 +358,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
// ==================== LYRICS PROVIDER SETTINGS ====================
/// Sets the lyrics provider order. Providers not in the list are disabled.
static Future<void> setLyricsProviders(List<String> providers) async {
final providersJSON = jsonEncode(providers);
@@ -519,21 +472,6 @@ class PlatformBridge {
return result as bool;
}
static Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
await _channel.invokeMethod('setSpotifyCredentials', {
'client_id': clientId,
'client_secret': clientSecret,
});
}
static Future<bool> hasSpotifyCredentials() async {
final result = await _channel.invokeMethod('hasSpotifyCredentials');
return result as bool;
}
static Future<void> preWarmTrackCache(
List<Map<String, String>> tracks,
) async {
@@ -1060,8 +998,6 @@ class PlatformBridge {
}
}
// ==================== LOCAL LIBRARY SCANNING ====================
/// Set the directory for caching extracted cover art
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
_log.i('setLibraryCoverCacheDir: $cacheDir');
@@ -1159,6 +1095,47 @@ class PlatformBridge {
await _channel.invokeMethod('cancelLibraryScan');
}
// MARK: - iOS Security-Scoped Bookmark
/// Create a security-scoped bookmark from a filesystem path picked by
/// FilePicker on iOS. Must be called while the picker session is still active.
/// Returns base64-encoded bookmark data, or null on failure.
static Future<String?> createIosBookmarkFromPath(String path) async {
try {
final result = await _channel.invokeMethod('createIosBookmarkFromPath', {
'path': path,
});
return result as String?;
} catch (e) {
_log.w('Failed to create iOS bookmark from path: $e');
return null;
}
}
/// Resolve a base64-encoded iOS security-scoped bookmark and start accessing
/// the resource. Returns the resolved filesystem path.
/// The resource stays accessed until [stopAccessingIosBookmark] is called.
static Future<String?> startAccessingIosBookmark(String bookmark) async {
try {
final result = await _channel.invokeMethod('startAccessingIosBookmark', {
'bookmark': bookmark,
});
return result as String?;
} catch (e) {
_log.w('Failed to start accessing iOS bookmark: $e');
return null;
}
}
/// Stop accessing the currently active iOS security-scoped resource.
static Future<void> stopAccessingIosBookmark() async {
try {
await _channel.invokeMethod('stopAccessingIosBookmark');
} catch (e) {
_log.w('Failed to stop accessing iOS bookmark: $e');
}
}
/// Read metadata from a single audio file
static Future<Map<String, dynamic>?> readAudioMetadata(
String filePath,
@@ -1261,5 +1238,19 @@ class PlatformBridge {
await _channel.invokeMethod('clearStoreCache');
}
// ==================== YOUTUBE / COBALT ====================
/// Parse a .cue file and return split information (track listing, timing, metadata).
/// Returns a map with: cue_path, audio_path, album, artist, genre, date, tracks[]
/// Each track has: number, title, artist, isrc, composer, start_sec, end_sec
/// [audioDir] optionally overrides the directory for audio file resolution (used for SAF).
static Future<Map<String, dynamic>> parseCueSheet(
String cuePath, {
String audioDir = '',
}) async {
_log.i('parseCueSheet: $cuePath (audioDir: $audioDir)');
final result = await _channel.invokeMethod('parseCueSheet', {
'cue_path': cuePath,
'audio_dir': audioDir,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
}
+9 -3
View File
@@ -31,7 +31,12 @@ class ShareIntentService {
// YouTube Music patterns
static final RegExp _ytMusicUrlPattern = RegExp(
r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/)[a-zA-Z0-9_-]+(\&[^\s]*)?',
r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/|browse/)[a-zA-Z0-9_-]+([?&][^\s]*)?',
);
// Standard YouTube patterns (youtu.be short links and www.youtube.com/watch)
static final RegExp _youtubeUrlPattern = RegExp(
r'https?://(youtu\.be/[a-zA-Z0-9_-]+|www\.youtube\.com/watch\?v=[a-zA-Z0-9_-]+)([?&][^\s]*)?',
);
final _sharedUrlController = StreamController<String>.broadcast();
@@ -101,14 +106,15 @@ class ShareIntentService {
_deezerShortLinkPattern,
_tidalUrlPattern,
_ytMusicUrlPattern,
_youtubeUrlPattern,
];
for (final pattern in patterns) {
final match = pattern.firstMatch(text);
if (match != null) {
final fullUrl = match.group(0)!;
// Remove query params for cleaner URL (except for YT Music which needs them)
if (pattern == _ytMusicUrlPattern) {
// Keep query params for YouTube URLs (needed for ?v=, ?list=, etc.)
if (pattern == _ytMusicUrlPattern || pattern == _youtubeUrlPattern) {
return fullUrl;
}
final queryIndex = fullUrl.indexOf('?');
+40 -10
View File
@@ -212,16 +212,39 @@ bool isContentUri(String? path) {
return path != null && path.startsWith('content://');
}
/// Pattern matching CUE virtual path suffixes like #track01, #track12, etc.
final _cueTrackSuffix = RegExp(r'#track\d+$');
const cueVirtualTrackRequiresSplitMessage =
'This CUE track is virtual. Use Split into Tracks first.';
/// Whether the path is a CUE virtual path (contains #trackNN suffix).
bool isCueVirtualPath(String? path) {
return path != null && _cueTrackSuffix.hasMatch(path);
}
/// Strip the #trackNN suffix from a CUE virtual path to get the base .cue path.
/// Returns the path unchanged if it's not a CUE virtual path.
String stripCueTrackSuffix(String path) {
return path.replaceFirst(_cueTrackSuffix, '');
}
Future<bool> fileExists(String? path) async {
if (path == null || path.isEmpty) return false;
if (isContentUri(path)) {
return PlatformBridge.safExists(path);
// For CUE virtual paths, check if the base .cue file exists
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
if (isContentUri(realPath)) {
return PlatformBridge.safExists(realPath);
}
return File(path).exists();
return File(realPath).exists();
}
Future<void> deleteFile(String? path) async {
if (path == null || path.isEmpty) return;
// CUE virtual paths should NOT be deleted through this function —
// deleting album.cue would remove ALL tracks. Callers should handle
// CUE deletion specially (e.g. only delete when all tracks are removed).
if (isCueVirtualPath(path)) return;
if (isContentUri(path)) {
await PlatformBridge.safDelete(path);
return;
@@ -233,8 +256,10 @@ Future<void> deleteFile(String? path) async {
Future<FileAccessStat?> fileStat(String? path) async {
if (path == null || path.isEmpty) return null;
if (isContentUri(path)) {
final stat = await PlatformBridge.safStat(path);
// For CUE virtual paths, stat the base .cue file
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
if (isContentUri(realPath)) {
final stat = await PlatformBridge.safStat(realPath);
final exists = stat['exists'] as bool? ?? true;
if (!exists) return null;
return FileAccessStat(
@@ -245,18 +270,23 @@ Future<FileAccessStat?> fileStat(String? path) async {
);
}
final stat = await FileStat.stat(path);
final stat = await FileStat.stat(realPath);
if (stat.type == FileSystemEntityType.notFound) return null;
return FileAccessStat(size: stat.size, modified: stat.modified);
}
Future<void> openFile(String path) async {
if (isContentUri(path)) {
await PlatformBridge.openContentUri(path, mimeType: '');
if (isCueVirtualPath(path)) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
final realPath = path;
if (isContentUri(realPath)) {
await PlatformBridge.openContentUri(realPath, mimeType: '');
return;
}
final mimeType = audioMimeTypeForPath(path);
final result = await OpenFilex.open(path, type: mimeType);
final mimeType = audioMimeTypeForPath(realPath);
final result = await OpenFilex.open(realPath, type: mimeType);
if (result.type != ResultType.done) {
throw Exception(result.message);
}
+104
View File
@@ -0,0 +1,104 @@
import 'dart:io';
const _androidStoragePathAliases = <String>[
'/storage/emulated/0',
'/storage/emulated/legacy',
'/storage/self/primary',
'/sdcard',
'/mnt/sdcard',
];
Set<String> buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
if (cleaned.isEmpty) return const {};
final keys = <String>{};
final visited = <String>{};
void addNormalized(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
if (!visited.add(trimmed)) return;
keys.add(trimmed);
keys.add(trimmed.toLowerCase());
if (trimmed.contains('\\')) {
final slash = trimmed.replaceAll('\\', '/');
if (slash != trimmed) {
addNormalized(slash);
}
}
if (trimmed.contains('%')) {
try {
final decoded = Uri.decodeFull(trimmed);
if (decoded != trimmed) {
addNormalized(decoded);
}
} catch (_) {}
}
Uri? parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {}
if (parsed != null && parsed.hasScheme) {
final withoutQueryOrFragment = parsed.replace(
query: null,
fragment: null,
);
final uriString = withoutQueryOrFragment.toString();
keys.add(uriString);
keys.add(uriString.toLowerCase());
if (parsed.scheme == 'file') {
try {
addNormalized(parsed.toFilePath());
} catch (_) {}
}
} else if (trimmed.startsWith('/')) {
try {
final asFileUri = Uri.file(trimmed).toString();
keys.add(asFileUri);
keys.add(asFileUri.toLowerCase());
} catch (_) {}
}
if (Platform.isAndroid) {
for (final alias in _androidEquivalentPaths(trimmed)) {
if (alias != trimmed) {
addNormalized(alias);
}
}
}
}
addNormalized(cleaned);
return keys;
}
Iterable<String> _androidEquivalentPaths(String path) {
final normalized = path.replaceAll('\\', '/');
final lower = normalized.toLowerCase();
String? suffix;
for (final prefix in _androidStoragePathAliases) {
if (lower == prefix) {
suffix = '';
break;
}
final withSlash = '$prefix/';
if (lower.startsWith(withSlash)) {
suffix = normalized.substring(prefix.length);
break;
}
}
if (suffix == null) return const [];
return _androidStoragePathAliases.map((prefix) => '$prefix$suffix');
}
+44
View File
@@ -5,3 +5,47 @@ String? normalizeOptionalString(String? value) {
if (trimmed.toLowerCase() == 'null') return null;
return trimmed;
}
String formatSampleRateKHz(int sampleRate) {
final khz = sampleRate / 1000;
final precision = sampleRate % 1000 == 0 ? 0 : 1;
return '${khz.toStringAsFixed(precision)}kHz';
}
String? buildDisplayAudioQuality({
int? bitDepth,
int? sampleRate,
int? bitrateKbps,
String? format,
String? storedQuality,
}) {
if (bitrateKbps != null && bitrateKbps > 0) {
final normalizedFormat = normalizeOptionalString(format)?.toUpperCase();
return normalizedFormat != null
? '$normalizedFormat ${bitrateKbps}kbps'
: '${bitrateKbps}kbps';
}
if (bitDepth != null &&
bitDepth > 0 &&
sampleRate != null &&
sampleRate > 0) {
return '$bitDepth-bit/${formatSampleRateKHz(sampleRate)}';
}
return normalizeOptionalString(storedQuality);
}
bool isPlaceholderQualityLabel(String? quality) {
final normalized = normalizeOptionalString(quality)?.toLowerCase();
if (normalized == null) return false;
return const {
'best',
'lossless',
'hi-res',
'hi-res-max',
'high',
'cd',
}.contains(normalized);
}
+23 -6
View File
@@ -32,6 +32,7 @@ class CollapsingHeader extends StatelessWidget {
surfaceTintColor: Colors.transparent,
leading: showBackButton
? IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
)
@@ -39,7 +40,10 @@ class CollapsingHeader extends StatelessWidget {
automaticallyImplyLeading: false,
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final expandRatio = _calculateExpandRatio(constraints, topPadding);
final expandRatio = _calculateExpandRatio(
constraints,
topPadding,
);
final animation = AlwaysStoppedAnimation(expandRatio);
return FlexibleSpaceBar(
@@ -48,13 +52,22 @@ class CollapsingHeader extends StatelessWidget {
title: Container(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: Tween<double>(begin: showBackButton ? 56 : 24, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 24).evaluate(animation),
left: Tween<double>(
begin: showBackButton ? 56 : 24,
end: 24,
).evaluate(animation),
bottom: Tween<double>(
begin: 16,
end: 24,
).evaluate(animation),
),
child: Text(
title,
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontSize: Tween<double>(
begin: 20,
end: 28,
).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
@@ -142,8 +155,12 @@ class InfoCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant)),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
-2
View File
@@ -28,14 +28,12 @@ class _KofiPainter extends CustomPainter {
..color = color
..style = PaintingStyle.fill;
// Cup body
final cup = RRect.fromRectAndRadius(
Rect.fromLTWH(s * 0.08, s * 0.28, s * 0.62, s * 0.52),
Radius.circular(s * 0.12),
);
canvas.drawRRect(cup, paint);
// Handle
final handlePaint = Paint()
..color = color
..style = PaintingStyle.stroke
+2 -14
View File
@@ -67,25 +67,14 @@ const _builtInServices = [
),
],
),
BuiltInService(
id: 'amazon',
label: 'Amazon',
qualityOptions: [
QualityOption(
id: 'LOSSLESS',
label: 'FLAC Best Available',
description: 'Amazon API delivers the best available lossless quality',
),
],
),
BuiltInService(
id: 'deezer',
label: 'Deezer',
qualityOptions: [
QualityOption(
id: 'FLAC',
label: 'FLAC Lossless',
description: '16-bit / 44.1kHz (CD Quality)',
label: 'FLAC Best Quality',
description: 'Up to 24-bit / 48kHz+',
),
],
),
@@ -209,7 +198,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return ext.qualityOptions;
}
// Default fallback options
return [
const QualityOption(
id: 'DEFAULT',
@@ -61,6 +61,7 @@ class PrioritySettingsScaffold extends StatelessWidget {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => _handleBack(context),
),
@@ -36,6 +36,7 @@ class TrackCollectionQuickActions extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
return IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
icon: Icon(
Icons.more_vert,
color: colorScheme.onSurfaceVariant,