mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-12 02:37:18 +02:00
Merge branch 'dev'
This commit is contained in:
commit
29165da5ac
136 files changed
+12862
-7088
No files matched your search
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+422
-345
File diff suppressed because it is too large.
Load diff
@@ -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';
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
+285
-238
File diff suppressed because it is too large.
Load diff
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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 =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+2634
-2112
File diff suppressed because it is too large.
Load diff
+583
-281
File diff suppressed because it is too large.
Load diff
+107
-3
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+303
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() ??
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user