Merge dev into main: v3.6.7 release

This commit is contained in:
zarzet
2026-02-13 21:42:02 +07:00
72 changed files with 24173 additions and 4044 deletions
+19 -15
View File
@@ -10,9 +10,13 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
final isFirstLaunch = ref.watch(
settingsProvider.select((s) => s.isFirstLaunch),
);
final hasCompletedTutorial = ref.watch(
settingsProvider.select((s) => s.hasCompletedTutorial),
);
// Determine initial location based on app state
String initialLocation;
if (isFirstLaunch) {
@@ -22,18 +26,12 @@ final _routerProvider = Provider<GoRouter>((ref) {
} else {
initialLocation = '/';
}
return GoRouter(
initialLocation: initialLocation,
routes: [
GoRoute(
path: '/',
builder: (context, state) => const MainShell(),
),
GoRoute(
path: '/setup',
builder: (context, state) => const SetupScreen(),
),
GoRoute(path: '/', builder: (context, state) => const MainShell()),
GoRoute(path: '/setup', builder: (context, state) => const SetupScreen()),
GoRoute(
path: '/tutorial',
builder: (context, state) => const TutorialScreen(),
@@ -43,13 +41,18 @@ final _routerProvider = Provider<GoRouter>((ref) {
});
class SpotiFLACApp extends ConsumerWidget {
const SpotiFLACApp({super.key});
final bool disableOverscrollEffects;
const SpotiFLACApp({super.key, this.disableOverscrollEffects = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
final scrollBehavior = disableOverscrollEffects
? const MaterialScrollBehavior().copyWith(overscroll: false)
: null;
Locale? locale;
if (localeString != 'system') {
if (localeString.contains('_')) {
@@ -59,7 +62,7 @@ class SpotiFLACApp extends ConsumerWidget {
locale = Locale(localeString);
}
}
return DynamicColorWrapper(
builder: (lightTheme, darkTheme, themeMode) {
return MaterialApp.router(
@@ -68,6 +71,7 @@ class SpotiFLACApp extends ConsumerWidget {
theme: lightTheme,
darkTheme: darkTheme,
themeMode: themeMode,
scrollBehavior: scrollBehavior,
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
routerConfig: router,
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.6.5';
static const String buildNumber = '79';
static const String version = '3.6.7';
static const String buildNumber = '81';
static const String fullVersion = '$version+$buildNumber';
+12
View File
@@ -2152,6 +2152,18 @@ abstract class AppLocalizations {
/// **'{artist} - {title}'**
String filenameHint(Object artist, Object title);
/// Toggle label for showing advanced filename tags
///
/// In en, this message translates to:
/// **'Show advanced tags'**
String get filenameShowAdvancedTags;
/// Description for advanced filename tag toggle
///
/// In en, this message translates to:
/// **'Enable formatted tags for track padding and date patterns'**
String get filenameShowAdvancedTagsDescription;
/// Setting title - folder structure
///
/// In en, this message translates to:
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1182,6 +1182,13 @@ class AppLocalizationsEn extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
File diff suppressed because it is too large Load Diff
+92 -79
View File
@@ -13,62 +13,62 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Téléchargez des pistes Spotify en qualité sans perte de Tidal, Qobuz et Amazon Music.';
@override
String get navHome => 'Home';
String get navHome => 'Accueil';
@override
String get navLibrary => 'Library';
String get navLibrary => 'Bibliothèques';
@override
String get navHistory => 'History';
String get navHistory => 'Historique';
@override
String get navSettings => 'Settings';
String get navSettings => 'Paramètres';
@override
String get navStore => 'Store';
String get navStore => 'Magasin';
@override
String get homeTitle => 'Home';
String get homeTitle => 'Accueil';
@override
String get homeSearchHint => 'Paste Spotify URL or search...';
String get homeSearchHint => 'Coller l\'URL Spotify ou rechercher...';
@override
String homeSearchHintExtension(String extensionName) {
return 'Search with $extensionName...';
return 'Rechercher avec $extensionName...';
}
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
@override
String get homeRecent => 'Recent';
String get homeRecent => 'Récent';
@override
String get historyTitle => 'History';
String get historyTitle => 'Historique';
@override
String historyDownloading(int count) {
return 'Downloading ($count)';
return 'Téléchargement ($count)';
}
@override
String get historyDownloaded => 'Downloaded';
String get historyDownloaded => 'Téléchargé';
@override
String get historyFilterAll => 'All';
String get historyFilterAll => 'Tous';
@override
String get historyFilterAlbums => 'Albums';
@override
String get historyFilterSingles => 'Singles';
String get historyFilterSingles => 'Titres';
@override
String historyTracksCount(int count) {
@@ -93,36 +93,37 @@ class AppLocalizationsFr extends AppLocalizations {
}
@override
String get historyNoDownloads => 'No download history';
String get historyNoDownloads => 'Pas d\'historique de téléchargement';
@override
String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
String get historyNoDownloadsSubtitle =>
'Les pistes téléchargées apparaîtront ici';
@override
String get historyNoAlbums => 'No album downloads';
String get historyNoAlbums => 'Pas de téléchargement d\'album';
@override
String get historyNoAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
'Téléchargez plusieurs titres d\'un album pour les voir ici';
@override
String get historyNoSingles => 'No single downloads';
String get historyNoSingles => 'Pas de téléchargements uniques';
@override
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
'Les téléchargements de pistes uniques apparaîtront ici';
@override
String get historySearchHint => 'Search history...';
String get historySearchHint => 'Historique de recherche...';
@override
String get settingsTitle => 'Settings';
String get settingsTitle => 'Paramètres';
@override
String get settingsDownload => 'Download';
String get settingsDownload => 'Télécharger';
@override
String get settingsAppearance => 'Appearance';
String get settingsAppearance => 'Apparence';
@override
String get settingsOptions => 'Options';
@@ -131,51 +132,54 @@ class AppLocalizationsFr extends AppLocalizations {
String get settingsExtensions => 'Extensions';
@override
String get settingsAbout => 'About';
String get settingsAbout => 'À propos';
@override
String get downloadTitle => 'Download';
String get downloadTitle => 'Télécharger';
@override
String get downloadLocation => 'Download Location';
String get downloadLocation => 'Télécharger Localisation';
@override
String get downloadLocationSubtitle => 'Choose where to save files';
String get downloadLocationSubtitle =>
'Choisissez où enregistrer des fichiers';
@override
String get downloadLocationDefault => 'Default location';
String get downloadLocationDefault => 'Localisation par défaut';
@override
String get downloadDefaultService => 'Default Service';
String get downloadDefaultService => 'Service par défaut';
@override
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
String get downloadDefaultServiceSubtitle =>
'Service utilisé pour les téléchargements';
@override
String get downloadDefaultQuality => 'Default Quality';
String get downloadDefaultQuality => 'Qualité par défaut';
@override
String get downloadAskQuality => 'Ask Quality Before Download';
String get downloadAskQuality =>
'Demandez La Qualité Avant Le Téléchargement';
@override
String get downloadAskQualitySubtitle =>
'Show quality picker for each download';
'Afficher le sélecteur de qualité pour chaque téléchargement';
@override
String get downloadFilenameFormat => 'Filename Format';
String get downloadFilenameFormat => 'Nom du fichier';
@override
String get downloadFolderOrganization => 'Folder Organization';
String get downloadFolderOrganization => 'Organisation du dossier';
@override
String get downloadSeparateSingles => 'Separate Singles';
String get downloadSeparateSingles => 'Titres séparés';
@override
String get downloadSeparateSinglesSubtitle =>
'Put single tracks in a separate folder';
'Mettre des pistes uniques dans un dossier séparé';
@override
String get qualityBest => 'Best Available';
String get qualityBest => 'Meilleur Disponible';
@override
String get qualityFlac => 'FLAC';
@@ -187,69 +191,71 @@ class AppLocalizationsFr extends AppLocalizations {
String get quality128 => '128 kbps';
@override
String get appearanceTitle => 'Appearance';
String get appearanceTitle => 'Apparence';
@override
String get appearanceTheme => 'Theme';
String get appearanceTheme => 'Thème';
@override
String get appearanceThemeSystem => 'System';
String get appearanceThemeSystem => 'Système';
@override
String get appearanceThemeLight => 'Light';
String get appearanceThemeLight => 'Clair';
@override
String get appearanceThemeDark => 'Dark';
String get appearanceThemeDark => 'Sombre';
@override
String get appearanceDynamicColor => 'Dynamic Color';
String get appearanceDynamicColor => 'Couleur dynamique';
@override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
String get appearanceDynamicColorSubtitle =>
'Utilisez les couleurs de votre fond d\'écran';
@override
String get appearanceAccentColor => 'Accent Color';
String get appearanceAccentColor => 'Couleur d\'accent';
@override
String get appearanceHistoryView => 'History View';
String get appearanceHistoryView => 'Historique Vue';
@override
String get appearanceHistoryViewList => 'List';
String get appearanceHistoryViewList => '';
@override
String get appearanceHistoryViewGrid => 'Grid';
String get appearanceHistoryViewGrid => 'Grille';
@override
String get optionsTitle => 'Options';
@override
String get optionsSearchSource => 'Search Source';
String get optionsSearchSource => 'Recherche Source';
@override
String get optionsPrimaryProvider => 'Primary Provider';
String get optionsPrimaryProvider => 'Fournisseur principal';
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Service utilisé lors de la recherche par nom de piste.';
@override
String optionsUsingExtension(String extensionName) {
return 'Using extension: $extensionName';
return 'Utilisation de l\'extension: $extensionName';
}
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@override
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
'Essayez d\'autres services si le téléchargement échoue';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
String get optionsUseExtensionProviders =>
'Utiliser des fournisseurs d\'extension';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
@@ -376,16 +382,16 @@ class AppLocalizationsFr extends AppLocalizations {
}
@override
String get extensionsUninstall => 'Uninstall';
String get extensionsUninstall => 'Désinstaller';
@override
String get extensionsSetAsSearch => 'Set as Search Provider';
String get extensionsSetAsSearch => 'Défini comme fournisseur de recherche';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => 'Magasin d\'extension';
@override
String get storeSearch => 'Search extensions...';
String get storeSearch => 'Recherche d\'extensions...';
@override
String get storeInstall => 'Install';
@@ -567,7 +573,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get trackMetadataDuration => 'Duration';
@override
String get trackMetadataQuality => 'Quality';
String get trackMetadataQuality => '';
@override
String get trackMetadataPath => 'File Path';
@@ -579,38 +585,38 @@ class AppLocalizationsFr extends AppLocalizations {
String get trackMetadataService => 'Service';
@override
String get trackMetadataPlay => 'Play';
String get trackMetadataPlay => 'Jouer';
@override
String get trackMetadataShare => 'Share';
String get trackMetadataShare => 'Partager';
@override
String get trackMetadataDelete => 'Delete';
String get trackMetadataDelete => 'Supprimer';
@override
String get trackMetadataRedownload => 'Re-download';
String get trackMetadataRedownload => 'Re-télécharger';
@override
String get trackMetadataOpenFolder => 'Open Folder';
String get trackMetadataOpenFolder => 'Dossier ouvert';
@override
String get setupTitle => 'Welcome to SpotiFLAC';
String get setupTitle => 'Bienvenue chez SpotiFLAC';
@override
String get setupSubtitle => 'Let\'s get you started';
String get setupSubtitle => 'On va commencer';
@override
String get setupStoragePermission => 'Storage Permission';
String get setupStoragePermission => 'Permission de stockage';
@override
String get setupStoragePermissionSubtitle =>
'Required to save downloaded files';
'Requis pour enregistrer les fichiers téléchargés';
@override
String get setupStoragePermissionGranted => 'Permission granted';
String get setupStoragePermissionGranted => 'Permission accordée';
@override
String get setupStoragePermissionDenied => 'Permission denied';
String get setupStoragePermissionDenied => 'Permission refusée';
@override
String get setupGrantPermission => 'Grant Permission';
@@ -735,14 +741,14 @@ class AppLocalizationsFr extends AppLocalizations {
'Get notified when downloads complete or require attention.';
@override
String get setupFolderSelected => 'Download Folder Selected!';
String get setupFolderSelected => 'Dossier de téléchargement sélectionné!';
@override
String get setupFolderChoose => 'Choose Download Folder';
String get setupFolderChoose => 'Choisissez le dossier pour télécharger';
@override
String get setupFolderDescription =>
'Select a folder where your downloaded music will be saved.';
'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.';
@override
String get setupChangeFolder => 'Change Folder';
@@ -1182,6 +1188,13 @@ class AppLocalizationsFr extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
+7
View File
@@ -1182,6 +1182,13 @@ class AppLocalizationsHi extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
+113 -112
View File
@@ -349,7 +349,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get optionsSpotifyDeprecationWarning =>
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Ekstensi';
@@ -1188,6 +1188,13 @@ class AppLocalizationsId extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
@override
String get filenameShowAdvancedTagsDescription =>
'Aktifkan tag format untuk padding nomor lagu dan pola tanggal';
@override
String get folderOrganization => 'Organisasi Folder';
@@ -1941,27 +1948,26 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get downloadUseAlbumArtistForFolders =>
'Gunakan Album Artist untuk folder';
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Folder artis memakai Album Artist jika tersedia';
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Folder artis hanya memakai Track Artist';
'Artist folders use Track Artist only';
@override
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artist dihapus dari nama folder (misal Justin Bieber, Quavo → Justin Bieber)';
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Nama artis lengkap dipakai untuk folder';
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Simpan Format';
@@ -2200,10 +2206,10 @@ class AppLocalizationsId extends AppLocalizations {
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'Belum ada item terbaru';
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Tampilkan Semua Download';
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
@@ -2312,10 +2318,10 @@ class AppLocalizationsId extends AppLocalizations {
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Penyimpanan & Cache';
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2590,221 +2596,219 @@ class AppLocalizationsId extends AppLocalizations {
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Metadata, cover art, dan lirik otomatis tertanam';
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Mencari Musik';
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Tempel URL Spotify atau Deezer langsung di kotak pencarian';
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Atau ketik nama lagu, artis, atau album untuk mencari';
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Mendukung lagu, album, playlist, dan halaman artis';
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Mengunduh Musik';
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Mengunduh musik itu mudah dan cepat. Begini caranya.';
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh';
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)';
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Unduh seluruh album atau playlist dengan satu ketukan';
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Perpustakaan Anda';
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.';
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'Lihat progres unduhan dan antrian di tab Perpustakaan';
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik';
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik';
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Ekstensi';
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.';
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Jelajahi tab Toko untuk menemukan ekstensi berguna';
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Tambahkan provider unduhan atau sumber pencarian baru';
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Dapatkan lirik, metadata lebih baik, dan fitur lainnya';
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda';
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.';
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Ubah lokasi unduhan dan organisasi folder';
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Atur kualitas audio dan preferensi format default';
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi';
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'Anda siap! Mulai unduh musik favorit Anda sekarang.';
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'CONTOH';
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Pindai Ulang Penuh';
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle =>
'Pindai ulang semua file, abaikan cache';
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Bersihkan Entri Unduhan Tidak Valid';
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Hapus entri riwayat untuk file yang tidak ada lagi';
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Menghapus $count entri unduhan tidak valid dari riwayat';
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone =>
'Tidak ada entri unduhan tidak valid';
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Penyimpanan & Cache';
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Ringkasan cache';
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimasi penggunaan cache: $size';
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Data Cache';
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Perawatan';
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'Direktori cache aplikasi';
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'Respons HTTP, data WebView, dan data sementara aplikasi.';
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Direktori sementara';
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'File sementara dari proses download dan konversi audio.';
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cache gambar cover';
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Cache cover library';
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Cache feed Explore';
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Cache pencocokan lagu';
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'Tidak ada data cache';
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size dalam $count file';
return '$size in $count files';
}
@override
@@ -2814,126 +2818,123 @@ class AppLocalizationsId extends AppLocalizations {
@override
String cacheEntries(int count) {
return '$count entri';
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Berhasil dibersihkan: $target';
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Bersihkan cache?';
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Bersihkan semua cache';
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Segarkan statistik';
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Simpan Cover Art';
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle =>
'Simpan cover album sebagai file .jpg';
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle =>
'Ambil dan simpan lirik sebagai file .lrc';
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackSaveLyricsProgress => 'Menyimpan lirik...';
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Perkaya Ulang Metadata';
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Tanamkan ulang metadata tanpa mengunduh ulang';
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Cari metadata dari internet dan tanamkan ke file';
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art disimpan ke $fileName';
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'Tidak ada sumber cover art';
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lirik disimpan ke $fileName';
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed =>
'Gagal menanamkan metadata via FFmpeg';
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Gagal: $error';
return 'Failed: $error';
}
@override
String get trackConvertFormat => 'Konversi Format';
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Konversi ke MP3 atau Opus';
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Konversi Audio';
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Format Tujuan';
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Konfirmasi Konversi';
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
@@ -2941,17 +2942,17 @@ class AppLocalizationsId extends AppLocalizations {
String targetFormat,
String bitrate,
) {
return 'Konversi dari $sourceFormat ke $targetFormat pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Mengkonversi audio...';
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Berhasil dikonversi ke $format';
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Konversi gagal';
String get trackConvertFailed => 'Conversion failed';
}
+7
View File
@@ -1176,6 +1176,13 @@ class AppLocalizationsJa extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'フォルダ構成';
+19 -13
View File
@@ -13,7 +13,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.';
@override
String get navHome => 'Home';
@@ -34,32 +34,32 @@ class AppLocalizationsKo extends AppLocalizations {
String get homeTitle => 'Home';
@override
String get homeSearchHint => 'Paste Spotify URL or search...';
String get homeSearchHint => 'Spotify URL을 붙여 넣거나 검색';
@override
String homeSearchHintExtension(String extensionName) {
return 'Search with $extensionName...';
return '$extensionName에서 검색';
}
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
@override
String get homeRecent => 'Recent';
String get homeRecent => '최근 기록';
@override
String get historyTitle => 'History';
String get historyTitle => '기록';
@override
String historyDownloading(int count) {
return 'Downloading ($count)';
return '다운로드 중... $count';
}
@override
String get historyDownloaded => 'Downloaded';
String get historyDownloaded => '다운로드 목록';
@override
String get historyFilterAll => 'All';
@@ -75,7 +75,7 @@ class AppLocalizationsKo extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
other: '${count}tracks',
one: '1 track',
);
return '$_temp0';
@@ -245,14 +245,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsAutoFallback => 'Auto Fallback';
@override
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
@@ -1182,6 +1181,13 @@ class AppLocalizationsKo extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
+7
View File
@@ -1182,6 +1182,13 @@ class AppLocalizationsNl extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Folder Organization';
File diff suppressed because it is too large Load Diff
+248 -175
View File
@@ -19,7 +19,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get navHome => 'Главная';
@override
String get navLibrary => 'Library';
String get navLibrary => 'Библиотека';
@override
String get navHistory => 'История';
@@ -356,7 +356,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
'Поиск Spotify устареет 3 марта 2026 года из-за изменений Spotify API. Пожалуйста, перейдите на Deezer.';
@override
String get extensionsTitle => 'Расширения';
@@ -486,7 +486,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
'Создатель I Don\'t Have Spotify (IDHS). Резервный резолвер ссылки';
@override
String get aboutDoubleDouble => 'DoubleDouble';
@@ -507,7 +507,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
'Потоковая передача Tidal Hi-Res FLAC. Ключевая часть lossless головоломки!';
@override
String get aboutAppDescription =>
@@ -712,7 +712,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
'iCloud Drive не поддерживается. Пожалуйста, используйте папку Документы.';
@override
String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC';
@@ -975,7 +975,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
return '\"$trackName\" уже есть в вашей библиотеке';
}
@override
@@ -1209,6 +1209,13 @@ class AppLocalizationsRu extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Организация папок';
@@ -1918,33 +1925,35 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
String get qualityLossyMp3Subtitle =>
'Opus 320 кбит/с (конвертировать из FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
String get qualityLossyOpusSubtitle =>
'Opus 128 кбит/с (конвертировать из FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
String get enableLossyOption => 'Включить опцию Lossy';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
String get enableLossyOptionSubtitleOn => 'Доступно качество с потерями';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
'Скачивать FLAC и конвертировать в MP3 320 кбит/с';
@override
String get lossyFormat => 'Lossy Format';
String get lossyFormat => 'Формат с потерями';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
String get lossyFormatDescription => 'Выберите Lossy формат для конвертации';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
String get lossyFormatMp3Subtitle => '320Кбит/с, лучшая совместимость';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
'128кбит/с, лучшее качество при меньших размерах';
@override
String get qualityNote =>
@@ -1952,7 +1961,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
'YouTube обеспечивает только звук с потерями(Lossy).';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1967,7 +1976,8 @@ class AppLocalizationsRu extends AppLocalizations {
String get downloadAlbumFolderStructure => 'Структура папок альбома';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
String get downloadUseAlbumArtistForFolders =>
'Использовать исполнителя альбома для папок';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
@@ -1975,7 +1985,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Папки исполнителя используют только трек исполнителя';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@@ -2069,37 +2090,37 @@ class AppLocalizationsRu extends AppLocalizations {
'Вы уверены, что хотите очистить все загрузки?';
@override
String get queueExportFailed => 'Export';
String get queueExportFailed => 'Экспорт';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
'Сбой при экспорте загрузок в файл TXT';
@override
String get queueExportFailedClear => 'Clear Failed';
String get queueExportFailedClear => 'Не удалось очистить';
@override
String get queueExportFailedError => 'Failed to export downloads';
String get queueExportFailedError => 'Не удалось экспортировать загрузки';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
String get settingsAutoExportFailed => 'Автоэкспорт неудачных загрузок';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
'Автоматическое сохранение неудачных загрузок в TXT файл';
@override
String get settingsDownloadNetwork => 'Download Network';
String get settingsDownloadNetwork => 'Сеть для скачивания';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
String get settingsDownloadNetworkAny => 'WiFi и мобильная сеть';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
String get settingsDownloadNetworkWifiOnly => 'Только WiFi';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
'Выберите, какую сеть использовать для скачивания. Когда установлено значение только WiFi — скачивания через мобильную сеть будут приостановлены.';
@override
String get queueEmpty => 'Нет загрузок в очереди';
@@ -2231,10 +2252,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get recentTypePlaylist => 'Плейлист';
@override
String get recentEmpty => 'No recent items yet';
String get recentEmpty => 'Нет недавних элементов';
@override
String get recentShowAllDownloads => 'Show All Downloads';
String get recentShowAllDownloads => 'Показать все загрузки';
@override
String recentPlaylistInfo(String name) {
@@ -2314,234 +2335,254 @@ class AppLocalizationsRu extends AppLocalizations {
'Не удалось получить некоторые альбомы';
@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';
String get allFilesAccessEnabledSubtitle => 'Можно записать в любую папку';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
String get allFilesAccessDisabledSubtitle =>
'Ограничено только папками медиа';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
'Включите, если вы сталкиваетесь с ошибками записи при сохранении в пользовательские папки. Android 13+ по умолчанию ограничивает доступ к определенным папкам.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
'В разрешении отказано. Пожалуйста, включите функцию «Доступ ко всем файлам» в настройках системы.';
@override
String get allFilesAccessDisabledMessage =>
'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';
String get settingsLocalLibrarySubtitle =>
'Сканировать и обнаружить дубликаты';
@override
String get settingsCache => 'Storage & Cache';
String get settingsCache => 'Хранилище и кэш';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
String get settingsCacheSubtitle => 'Просмотреть размер и очистить кэш';
@override
String get libraryTitle => 'Local Library';
String get libraryTitle => 'Локальная библиотека';
@override
String get libraryStatus => 'Library Status';
String get libraryStatus => 'Статус Библиотеки';
@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';
String get libraryShowDuplicateIndicator => 'Показать индикатор дубликатов';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'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';
String get libraryScanSelectFolderFirst => 'Сначала выберите папку';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
String get libraryCleanupMissingFiles => 'Очистка отсутствующих файлов';
@override
String get libraryCleanupMissingFilesSubtitle =>
'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';
String get libraryClearSubtitle => 'Удалить все сканированные треки';
@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 =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
'Сканирует существующую коллекцию музыки для обнаружения дубликатов при загрузке. Поддерживает форматы FLAC, M4A, MP3, Opus и OGG. Метаданные читаются из тегов файлов, если доступны.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'треков',
many: 'треков',
few: 'трека',
one: 'трек',
);
return '$count $_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
return 'Последнее сканирование: $time';
}
@override
String get libraryLastScannedNever => 'Never';
String get libraryLastScannedNever => 'Никогда';
@override
String get libraryScanning => 'Scanning...';
String get libraryScanning => 'Сканирование...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
return '$progress% из $total файлов';
}
@override
String get libraryInLibrary => 'In Library';
String get libraryInLibrary => 'В библиотеке';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'отсутствующих файлов',
many: 'отсутствующих файлов',
few: 'трека',
one: 'отсутствующий файл',
);
return 'Удалено $count $_temp0 в библиотеке';
}
@override
String get libraryCleared => 'Library cleared';
String get libraryCleared => 'Библиотека очищена';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
String get libraryStorageAccessRequired => 'Требуется доступ к хранилищу';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
'SpotiFLAC требуется доступ к хранилищу для сканирования вашей библиотеки музыки. Пожалуйста, предоставьте разрешение в настройках.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
String get libraryFolderNotExist => 'Выбранной папки не существует';
@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 => 'Hi-Res (24 бит)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
String get libraryFilterQualityCD => 'CD (16 бит)';
@override
String get libraryFilterQualityLossy => 'Lossy';
String get libraryFilterQualityLossy => 'С потерями';
@override
String get libraryFilterFormat => 'Format';
String get libraryFilterFormat => 'Формат';
@override
String get libraryFilterDate => 'Date Added';
String get libraryFilterDate => 'Дата добавления';
@override
String get libraryFilterDateToday => 'Today';
String get libraryFilterDateToday => 'Сегодня';
@override
String get libraryFilterDateWeek => 'This Week';
String get libraryFilterDateWeek => 'На этой неделе';
@override
String get libraryFilterDateMonth => 'This Month';
String get libraryFilterDateMonth => 'В этом месяце';
@override
String get libraryFilterDateYear => 'This Year';
String get libraryFilterDateYear => 'В этом году';
@override
String get libraryFilterSort => 'Sort';
String get libraryFilterSort => 'Сортировка';
@override
String get libraryFilterSortLatest => 'Latest';
String get libraryFilterSortLatest => 'Последние';
@override
String get libraryFilterSortOldest => 'Oldest';
String get libraryFilterSortOldest => 'Старые';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
return '$count фильтр(-ов) активно';
}
@override
String get timeJustNow => 'Just now';
String get timeJustNow => 'Только что';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
other: '$count минут',
many: '$count минут',
few: '$count минуты',
one: '$count минуту',
);
return '$_temp0';
return '$_temp0 назад';
}
@override
@@ -2549,160 +2590,186 @@ class AppLocalizationsRu extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
other: '$count часов',
many: '$count часов',
few: '$count часа',
one: '$count час',
);
return '$_temp0';
return '$_temp0 назад';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
String get storageSwitchTitle => 'Сменить режим хранения';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
String get storageSwitchToSafTitle => 'Переключиться на SAF хранилище?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
String get storageSwitchToAppTitle => 'Переключиться хранилище приложения?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
'Ваши скачанные файлы останутся в текущем расположении и будут доступны.\n\nНовые файлы будут сохранены в выбранной вами папке SAF.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
'Ваши скачанные файлы останутся в текущем выбранной вами папке SAF.\n\nНовые файлы будут сохранены в папке Music/SpotiFLAC.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
String get storageSwitchExistingDownloads => 'Существующие загрузки';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count треков',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0 в $mode хранилище';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
String get storageSwitchNewDownloads => 'Новые загрузки';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
return 'Будет сохранено в: $location';
}
@override
String get storageSwitchContinue => 'Continue';
String get storageSwitchContinue => 'Продолжить';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
String get storageSwitchSelectFolder => 'Выберите папку SAF';
@override
String get storageAppStorage => 'App Storage';
String get storageAppStorage => 'Хранилище приложения';
@override
String get storageSafStorage => 'SAF Storage';
String get storageSafStorage => 'Хранилище SAF';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
return 'Хранилище: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
String get storageStatsTitle => 'Статистика хранилища';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count треков',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0 в хранилище приложения';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count треков',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0 в вашей папке в SAF';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
String get storageModeInfo => 'Ваши файлы хранятся в нескольких местах';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
String get tutorialWelcomeTitle => 'Добро пожаловать в SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
'Давайте научимся скачивать свою любимую музыку в качестве без потерь. В этом кратком руководстве мы покажем вам основы.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
'Скачивайте музыку из Spotify, Deezer, или вставьте любой поддерживаемый URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
'Автоматическое встраивание метаданных, обложек и текстов песен';
@override
String get tutorialSearchTitle => 'Finding Music';
String get tutorialSearchTitle => 'Поиск музыки';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
'Есть два простых способа найти музыку, которую вы хотите скачать.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
'Вставьте ссылку Spotify или Deezer прямо в поле поиска';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
'Или введите название песни, исполнителя или альбом для поиска';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
'Поддержка треков, альбомов, плейлистов и страниц исполнителей';
@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 tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
'Нажмите кнопку скачать рядом с любым треком, чтобы начать скачивание';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
'Выберите предпочитаемое качество (FLAC, Hi-Res или MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
'Скачать все альбомы или плейлисты одним нажатием';
@override
String get tutorialLibraryTitle => 'Your Library';
String get tutorialLibraryTitle => 'Ваша библиотека';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
'Вся скачанная музыка организована во вкладке Библиотека.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
'Просмотр прогресса загрузки и очереди на вкладке Библиотека';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
'Нажмите на любой трек, чтобы воспроизвести его с помощью вашего музыкального плеера';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
'Переключение между списком и сеткой для лучшего просмотра';
@override
String get tutorialExtensionsTitle => 'Extensions';
String get tutorialExtensionsTitle => 'Расширения';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
'Расширьте возможности приложения с расширениями от сообщества.';
@override
String get tutorialExtensionsTip1 =>
@@ -2710,14 +2777,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
'Добавить новых поставщиков загрузок или поиска';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
String get tutorialSettingsTitle => 'Настройте приложение под себя';
@override
String get tutorialSettingsDesc =>
@@ -2725,27 +2792,28 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
'Изменить местоположение и организацию папок для скачивания';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
'Настройте качество и формата аудиофайла по умолчанию';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
String get tutorialSettingsTip3 => 'Настроить тему и внешний вид приложения';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
'Всё готово! Начните загружать любимую музыку прямо сейчас.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
String get libraryForceFullScan => 'Полное сканирование';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
String get libraryForceFullScanSubtitle =>
'Пересканировать все файлы, игнорировать кэш';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@@ -2763,10 +2831,10 @@ class AppLocalizationsRu 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 =>
@@ -2778,13 +2846,13 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
String get cacheSectionStorage => 'Cached Data';
String get cacheSectionStorage => 'Кэшированные данные';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
String get cacheAppDirectory => 'Папка кэша приложения';
@override
String get cacheAppDirectoryDesc =>
@@ -2830,11 +2898,11 @@ class AppLocalizationsRu 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) {
return '$size in $count files';
return '$size в $count файлах';
}
@override
@@ -2849,11 +2917,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
return 'Очищено: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
String get cacheClearConfirmTitle => 'Очистить кэш?';
@override
String cacheClearConfirmMessage(String target) {
@@ -2861,17 +2929,17 @@ class AppLocalizationsRu 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 =>
@@ -2883,19 +2951,23 @@ class AppLocalizationsRu 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';
String get trackSaveCoverArtSubtitle => 'Сохранить обложку как файл .jpg';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
String get trackSaveLyrics => 'Сохранить текст (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
String get trackSaveLyricsSubtitle =>
'Получить и сохранить текст песни в формате .lrc';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
@@ -2912,36 +2984,37 @@ class AppLocalizationsRu extends AppLocalizations {
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
String get trackEditMetadata => 'Редактировать метаданные';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
return 'Обложка сохранена в $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
String get trackCoverNoSource => 'Нет доступных источников обложки';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
return 'Текст песни сохранен в $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
String get trackReEnrichSearching => 'Поиск метаданных в сети...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
String get trackReEnrichFfmpegFailed =>
'Ошибка встраивания метаданных FFmpeg';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
return 'Ошибка: $error';
}
@override
+7
View File
@@ -1189,6 +1189,13 @@ class AppLocalizationsTr extends AppLocalizations {
return '$artist - $title';
}
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganization => 'Klasör Organizasyonu';
File diff suppressed because it is too large Load Diff
+1246 -234
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -874,6 +874,14 @@
"@filenameAvailablePlaceholders": {"description": "Label for placeholder list"},
"filenameHint": "{artist} - {title}",
"@filenameHint": {"description": "Default filename format hint"},
"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"
},
"folderOrganization": "Folder Organization",
"@folderOrganization": {"description": "Setting title - folder structure"},
+1276 -11
View File
File diff suppressed because it is too large Load Diff
+1107 -95
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+856 -221
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1041 -29
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1316 -51
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+55 -5
View File
@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
@@ -11,19 +12,68 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
_configureImageCache();
final runtimeProfile = await _resolveRuntimeProfile();
_configureImageCache(runtimeProfile);
runApp(
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
ProviderScope(
child: _EagerInitialization(
child: SpotiFLACApp(
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
),
),
),
);
}
void _configureImageCache() {
Future<_RuntimeProfile> _resolveRuntimeProfile() async {
const defaults = _RuntimeProfile(
imageCacheMaximumSize: 240,
imageCacheMaximumSizeBytes: 60 << 20,
disableOverscrollEffects: false,
);
if (!Platform.isAndroid) return defaults;
try {
final androidInfo = await DeviceInfoPlugin().androidInfo;
final isArm32Only = androidInfo.supported64BitAbis.isEmpty;
final isLowRamDevice =
androidInfo.isLowRamDevice || androidInfo.physicalRamSize <= 2500;
if (!isArm32Only && !isLowRamDevice) {
return defaults;
}
return _RuntimeProfile(
imageCacheMaximumSize: 120,
imageCacheMaximumSizeBytes: 24 << 20,
disableOverscrollEffects: true,
);
} catch (e) {
debugPrint('Failed to resolve runtime profile: $e');
return defaults;
}
}
void _configureImageCache(_RuntimeProfile runtimeProfile) {
final imageCache = PaintingBinding.instance.imageCache;
// Keep memory cache bounded so cover-heavy pages don't retain too many
// full-resolution images simultaneously.
imageCache.maximumSize = 240;
imageCache.maximumSizeBytes = 60 << 20; // 60 MiB
imageCache.maximumSize = runtimeProfile.imageCacheMaximumSize;
imageCache.maximumSizeBytes = runtimeProfile.imageCacheMaximumSizeBytes;
}
class _RuntimeProfile {
final int imageCacheMaximumSize;
final int imageCacheMaximumSizeBytes;
final bool disableOverscrollEffects;
const _RuntimeProfile({
required this.imageCacheMaximumSize,
required this.imageCacheMaximumSizeBytes,
required this.disableOverscrollEffects,
});
}
/// Widget to eagerly initialize providers that need to load data on startup
+34 -16
View File
@@ -21,6 +21,7 @@ class AppSettings {
final String folderOrganization;
final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final bool filterContributingArtistsInAlbumArtist;
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
@@ -36,18 +37,24 @@ class AppSettings {
final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
// Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
// Tutorial/Onboarding
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
const AppSettings({
this.defaultService = 'tidal',
@@ -67,6 +74,7 @@ class AppSettings {
this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false,
this.filterContributingArtistsInAlbumArtist = false,
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
@@ -112,6 +120,7 @@ class AppSettings {
String? folderOrganization,
bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly,
bool? filterContributingArtistsInAlbumArtist,
String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload,
@@ -157,18 +166,25 @@ class AppSettings {
folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly:
usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
filterContributingArtistsInAlbumArtist ??
this.filterContributingArtistsInAlbumArtist,
historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
askQualityBeforeDownload:
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
useCustomSpotifyCredentials:
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
@@ -176,12 +192,14 @@ class AppSettings {
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
// Tutorial
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
);
+4
View File
@@ -24,6 +24,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
filterContributingArtistsInAlbumArtist:
json['filterContributingArtistsInAlbumArtist'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -72,6 +74,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
'filterContributingArtistsInAlbumArtist':
instance.filterContributingArtistsInAlbumArtist,
'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
+225 -87
View File
@@ -323,7 +323,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) {
continue;
}
if (item.filePath.isEmpty || !isContentUri(item.filePath)) {
final hasFilePath = item.filePath.trim().isNotEmpty;
final hasSafFileName =
item.safFileName != null && item.safFileName!.trim().isNotEmpty;
if (!hasFilePath && !hasSafFileName) {
continue;
}
candidateIndexes.add(i);
@@ -344,52 +347,59 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
for (var c = 0; c < candidateIndexes.length; c++) {
final i = candidateIndexes[c];
final item = items[i];
final rawPath = item.filePath.trim();
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
final exists = await fileExists(item.filePath);
if (exists) {
final verified = item.copyWith(
safRepaired: true,
safFileName: item.safFileName ?? _fileNameFromUri(item.filePath),
);
updatedItems[i] = verified;
changed = true;
verifiedCount++;
await _db.upsert(verified.toJson());
} else {
final fallbackName =
item.safFileName ?? _fileNameFromUri(item.filePath);
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
if (isDirectSafUri) {
final exists = await fileExists(rawPath);
if (exists) {
final verified = item.copyWith(
safRepaired: true,
safFileName: item.safFileName ?? _fileNameFromUri(rawPath),
);
updatedItems[i] = verified;
changed = true;
verifiedCount++;
await _db.upsert(verified.toJson());
continue;
}
}
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = resolved['uri'] as String? ?? '';
if (newUri.isEmpty) continue;
var fallbackName = (item.safFileName ?? '').trim();
if (fallbackName.isEmpty && isDirectSafUri) {
fallbackName = _fileNameFromUri(rawPath);
}
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
continue;
}
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir:
(newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = (resolved['uri'] as String? ?? '').trim();
if (newUri.isEmpty) continue;
updatedItems[i] = updated;
changed = true;
repairedCount++;
await _db.upsert(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir:
(newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
updatedItems[i] = updated;
changed = true;
repairedCount++;
await _db.upsert(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
if ((c + 1) % _safRepairBatchSize == 0) {
@@ -421,19 +431,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
existing = state.getByIsrc(item.isrc!);
}
final mergedItem = existing == null
? item
: item.copyWith(
genre:
_normalizeOptionalString(item.genre) ??
_normalizeOptionalString(existing.genre),
label:
_normalizeOptionalString(item.label) ??
_normalizeOptionalString(existing.label),
copyright:
_normalizeOptionalString(item.copyright) ??
_normalizeOptionalString(existing.copyright),
);
if (existing != null) {
final updatedItems = state.items
.where((i) => i.id != existing!.id)
.toList();
updatedItems.insert(0, item);
updatedItems.insert(0, mergedItem);
state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}');
_historyLog.d('Updated existing history entry: ${mergedItem.trackName}');
} else {
state = state.copyWith(items: [item, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}');
state = state.copyWith(items: [mergedItem, ...state.items]);
_historyLog.d('Added new history entry: ${mergedItem.trackName}');
}
_db.upsert(item.toJson()).catchError((e) {
_db.upsert(mergedItem.toJson()).catchError((e) {
_historyLog.e('Failed to save to database: $e');
});
}
@@ -1173,11 +1197,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
}) async {
String baseDir = state.outputDir;
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
? normalizedAlbumArtist ?? track.artistName
: track.artistName;
if (useAlbumArtistForFolders &&
filterContributingArtistsInAlbumArtist &&
normalizedAlbumArtist != null) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
@@ -1273,7 +1304,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
static final _featuredArtistPattern = RegExp(
r'\s*[,;&]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
caseSensitive: false,
);
@@ -1285,6 +1316,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return artist;
}
String _resolveAlbumArtistForMetadata(Track track, AppSettings settings) {
var albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
if (settings.filterContributingArtistsInAlbumArtist) {
albumArtist = _extractPrimaryArtist(albumArtist);
}
return albumArtist;
}
bool _isSafMode(AppSettings settings) {
return Platform.isAndroid &&
settings.storageMode == 'saf' &&
@@ -1309,10 +1349,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
}) async {
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
? normalizedAlbumArtist ?? track.artistName
: track.artistName;
if (useAlbumArtistForFolders &&
filterContributingArtistsInAlbumArtist &&
normalizedAlbumArtist != null) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
@@ -1728,6 +1775,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
track,
settings,
);
if (!settings.useExtensionProviders) return;
@@ -1742,8 +1793,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'album_artist':
_normalizeOptionalString(track.albumArtist) ?? track.artistName,
'album_artist': resolvedAlbumArtist,
'track_number': track.trackNumber ?? 1,
'disc_number': track.discNumber ?? 1,
'isrc': track.isrc ?? '',
@@ -1803,7 +1853,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Track _buildTrackForMetadataEmbedding(
Track baseTrack,
Map<String, dynamic> backendResult,
String? normalizedAlbumArtist,
String resolvedAlbumArtist,
) {
final backendTrackNum = _parsePositiveInt(backendResult['track_number']);
final backendDiscNum = _parsePositiveInt(backendResult['disc_number']);
@@ -1826,7 +1876,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
name: baseTrack.name,
artistName: baseTrack.artistName,
albumName: backendAlbum ?? baseTrack.albumName,
albumArtist: normalizedAlbumArtist,
albumArtist: resolvedAlbumArtist,
coverUrl: baseTrack.coverUrl,
duration: baseTrack.duration,
isrc: baseTrack.isrc,
@@ -1890,16 +1940,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString();
}
@@ -2033,16 +2082,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString();
}
@@ -2198,15 +2246,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString();
}
@@ -2442,6 +2489,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
} else if (!isValidIosWritablePath(state.outputDir)) {
// Check for other invalid paths (like container root without Documents/)
_log.w(
@@ -2451,6 +2499,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final correctedPath = await validateOrFixIosPath(state.outputDir);
_log.i('Corrected path: $correctedPath');
state = state.copyWith(outputDir: correctedPath);
ref.read(settingsProvider.notifier).setDownloadDirectory(correctedPath);
}
}
@@ -2717,8 +2766,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
final normalizedAlbumArtist = _normalizeOptionalString(
trackToDownload.albumArtist,
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
trackToDownload,
settings,
);
final quality = item.qualityOverride ?? state.audioQuality;
@@ -2731,6 +2781,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
)
: '';
String? appOutputDir;
@@ -2743,6 +2795,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
);
var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode;
@@ -2759,6 +2813,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'track': trackToDownload.trackNumber ?? 0,
'disc': trackToDownload.discNumber ?? 0,
'year': _extractYear(trackToDownload.releaseDate) ?? '',
'date': trackToDownload.releaseDate ?? '',
});
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
safBaseName = sanitized;
@@ -2768,6 +2823,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? genre;
String? label;
String? copyright;
String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
@@ -2845,9 +2901,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(trackToDownload.isrc == null && deezerIsrc != null) ||
(!_isValidISRC(trackToDownload.isrc ?? '') &&
deezerIsrc != null) ||
(trackToDownload.trackNumber == null &&
deezerTrackNum != null) ||
(trackToDownload.discNumber == null && deezerDiscNum != null);
((trackToDownload.trackNumber == null ||
trackToDownload.trackNumber! <= 0) &&
deezerTrackNum != null &&
deezerTrackNum > 0) ||
((trackToDownload.discNumber == null ||
trackToDownload.discNumber! <= 0) &&
deezerDiscNum != null &&
deezerDiscNum > 0);
if (needsEnrich) {
trackToDownload = Track(
@@ -2861,8 +2922,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc
: trackToDownload.isrc,
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum,
discNumber: trackToDownload.discNumber ?? deezerDiscNum,
trackNumber:
(trackToDownload.trackNumber != null &&
trackToDownload.trackNumber! > 0)
? trackToDownload.trackNumber
: deezerTrackNum,
discNumber:
(trackToDownload.discNumber != null &&
trackToDownload.discNumber! > 0)
? trackToDownload.discNumber
: deezerDiscNum,
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
deezerId: deezerTrackId,
availability: trackToDownload.availability,
@@ -2889,8 +2958,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (extendedMetadata != null) {
genre = extendedMetadata['genre'];
label = extendedMetadata['label'];
copyright = extendedMetadata['copyright'];
if (genre != null && genre.isNotEmpty) {
_log.d('Extended metadata - Genre: $genre, Label: $label');
_log.d(
'Extended metadata - Genre: $genre, Label: $label, Copyright: $copyright',
);
}
}
} catch (e) {
@@ -2937,6 +3009,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
_log.d('Output dir: $outputDir');
final normalizedTrackNumber =
(trackToDownload.trackNumber != null &&
trackToDownload.trackNumber! > 0)
? trackToDownload.trackNumber!
: 1;
final normalizedDiscNumber =
(trackToDownload.discNumber != null &&
trackToDownload.discNumber! > 0)
? trackToDownload.discNumber!
: 1;
final payload = DownloadRequestPayload(
isrc: trackToDownload.isrc ?? '',
service: item.service,
@@ -2944,7 +3027,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist ?? trackToDownload.artistName,
albumArtist: resolvedAlbumArtist,
coverUrl: trackToDownload.coverUrl ?? '',
outputDir: outputDir,
filenameFormat: state.filenameFormat,
@@ -2952,14 +3035,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Keep prior behavior: non-YouTube paths were implicitly true.
embedLyrics: isYouTube ? settings.embedLyrics : true,
embedMaxQualityCover: settings.maxQualityCover,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
trackNumber: normalizedTrackNumber,
discNumber: normalizedDiscNumber,
releaseDate: trackToDownload.releaseDate ?? '',
itemId: item.id,
durationMs: trackToDownload.duration,
source: trackToDownload.source ?? '',
genre: genre ?? '',
label: label ?? '',
copyright: copyright ?? '',
deezerId: deezerTrackId ?? '',
lyricsMode: settings.lyricsMode,
storageMode: storageMode,
@@ -2992,6 +3076,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
);
final fallbackResult = await runDownload(
useSaf: false,
@@ -3329,7 +3415,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
@@ -3493,7 +3579,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
@@ -3553,7 +3639,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
@@ -3613,7 +3699,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
@@ -3650,7 +3736,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
@@ -3748,6 +3834,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
// SAF downloads should end with content URI. If we still have a
// transient FD path, recover URI from SAF metadata to keep history
// dedup/exclusion stable.
if (effectiveSafMode &&
filePath != null &&
filePath.isNotEmpty &&
!isContentUri(filePath) &&
settings.downloadTreeUri.isNotEmpty) {
final fallbackName = (finalSafFileName ?? safFileName ?? '').trim();
if (fallbackName.isNotEmpty) {
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: fallbackName,
);
final resolvedUri = (resolved['uri'] as String? ?? '').trim();
final resolvedRelativeDir =
(resolved['relative_dir'] as String? ?? '').trim();
if (resolvedUri.isNotEmpty && isContentUri(resolvedUri)) {
_log.w('Recovered SAF URI from transient path: $filePath');
filePath = resolvedUri;
finalSafFileName = fallbackName;
if (resolvedRelativeDir.isNotEmpty) {
effectiveOutputDir = resolvedRelativeDir;
}
} else {
_log.w(
'Failed to recover SAF URI (fileName=$fallbackName, dir=$effectiveOutputDir)',
);
}
} catch (e) {
_log.w('SAF URI recovery failed: $e');
}
} else {
_log.w(
'SAF download returned non-URI path without filename metadata: $filePath',
);
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
@@ -3840,13 +3967,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
final effectiveGenre =
_normalizeOptionalString(backendGenre) ??
_normalizeOptionalString(genre) ??
_normalizeOptionalString(existingInHistory?.genre);
final effectiveLabel =
_normalizeOptionalString(backendLabel) ??
_normalizeOptionalString(label) ??
_normalizeOptionalString(existingInHistory?.label);
final effectiveCopyright =
_normalizeOptionalString(backendCopyright) ??
_normalizeOptionalString(copyright) ??
_normalizeOptionalString(existingInHistory?.copyright);
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final historyAlbumArtist =
(normalizedAlbumArtist != null &&
normalizedAlbumArtist != trackToDownload.artistName)
? normalizedAlbumArtist
resolvedAlbumArtist != trackToDownload.artistName
? resolvedAlbumArtist
: null;
final isMp3 = filePath.endsWith('.mp3');
@@ -3899,9 +4037,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
quality: actualQuality,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
genre: backendGenre,
label: backendLabel,
copyright: backendCopyright,
genre: effectiveGenre,
label: effectiveLabel,
copyright: effectiveCopyright,
),
);
+169 -4
View File
@@ -3,8 +3,10 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/history_database.dart';
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';
@@ -116,6 +118,7 @@ class LocalLibraryState {
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance;
final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer;
bool _isLoaded = false;
@@ -180,6 +183,58 @@ 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>{cleaned};
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 (_) {}
}
}
addNormalized(cleaned);
if (cleaned.startsWith('content://')) {
try {
final uri = Uri.parse(cleaned);
addNormalized(uri.toString());
addNormalized(uri.replace(query: null, fragment: null).toString());
} catch (_) {}
}
return keys;
}
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false;
}
final candidateKeys = _buildPathMatchKeys(filePath);
for (final key in candidateKeys) {
if (downloadedPathKeys.contains(key)) {
return true;
}
}
return false;
}
Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
@@ -202,6 +257,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanErrorCount: 0,
scanWasCancelled: false,
);
await _showScanProgressNotification(
progress: 0,
scannedFiles: 0,
totalFiles: 0,
currentFile: null,
);
try {
final appSupportDir = await getApplicationSupportDirectory();
@@ -217,10 +278,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final isSaf = folderPath.startsWith('content://');
// Get all file paths from download history to exclude them
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
// been flushed to SQLite yet.
final downloadedPaths = await _historyDb.getAllFilePaths();
final inMemoryHistoryPaths = ref
.read(downloadHistoryProvider)
.items
.map((item) => item.filePath)
.where((path) => path.isNotEmpty);
final allHistoryPaths = <String>{
...downloadedPaths,
...inMemoryHistoryPaths,
};
final downloadedPathKeys = <String>{};
for (final path in allHistoryPaths) {
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
}
_log.i(
'Excluding ${downloadedPaths.length} downloaded files from library scan',
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
'(${downloadedPathKeys.length} path keys)',
);
if (forceFullScan) {
@@ -230,6 +307,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
: await PlatformBridge.scanLibraryFolder(folderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return;
}
@@ -238,7 +316,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in results) {
final filePath = json['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) {
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
}
@@ -275,6 +353,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'Full scan complete: ${items.length} tracks found, '
'$skippedDownloads already in downloads',
);
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
@@ -308,6 +391,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return;
}
@@ -344,7 +428,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in scannedList) {
final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?;
if (filePath != null && downloadedPaths.contains(filePath)) {
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
}
@@ -399,10 +483,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
);
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
}
} catch (e, stack) {
_log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false, scanWasCancelled: false);
await _showScanFailedNotification(e.toString());
} finally {
_stopProgressPolling();
}
@@ -441,6 +531,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scannedFiles: scannedFiles,
scanErrorCount: errorCount,
);
await _showScanProgressNotification(
progress: normalizedProgress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: currentFile,
);
}
if (progress['is_complete'] == true) {
@@ -473,6 +569,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false, scanWasCancelled: true);
_stopProgressPolling();
await _showScanCancelledNotification();
}
Future<void> _showScanProgressNotification({
required double progress,
required int scannedFiles,
required int totalFiles,
required String? currentFile,
}) async {
try {
await _notificationService.showLibraryScanProgress(
progress: progress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: _shortenFileForNotification(currentFile),
);
} catch (e) {
_log.w('Failed to show scan progress notification: $e');
}
}
Future<void> _showScanCompleteNotification({
required int totalTracks,
required int excludedDownloadedCount,
required int errorCount,
}) async {
try {
await _notificationService.showLibraryScanComplete(
totalTracks: totalTracks,
excludedDownloadedCount: excludedDownloadedCount,
errorCount: errorCount,
);
} catch (e) {
_log.w('Failed to show scan complete notification: $e');
}
}
Future<void> _showScanFailedNotification(String message) async {
try {
await _notificationService.showLibraryScanFailed(message);
} catch (e) {
_log.w('Failed to show scan failure notification: $e');
}
}
Future<void> _showScanCancelledNotification() async {
try {
await _notificationService.showLibraryScanCancelled();
} catch (e) {
_log.w('Failed to show scan cancelled notification: $e');
}
}
String? _shortenFileForNotification(String? path) {
final raw = path?.trim() ?? '';
if (raw.isEmpty) return null;
var decoded = raw;
try {
decoded = Uri.decodeFull(raw);
} catch (_) {}
final slashIdx = decoded.lastIndexOf('/');
final backslashIdx = decoded.lastIndexOf('\\');
final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx;
if (cut >= 0 && cut < decoded.length - 1) {
return decoded.substring(cut + 1);
}
return decoded;
}
Future<int> cleanupMissingFiles() async {
+5
View File
@@ -236,6 +236,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setFilterContributingArtistsInAlbumArtist(bool enabled) {
state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
+170 -54
View File
@@ -490,6 +490,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
0,
(sum, a) => sum + a.totalTracks,
);
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
final compactLayout =
MediaQuery.sizeOf(context).width < 430 || textScale > 1.15;
return Positioned(
left: 0,
@@ -510,53 +513,145 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
IconButton(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
tooltip: context.l10n.dialogCancel,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: compactLayout
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.discographySelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
Row(
children: [
IconButton(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
tooltip: context.l10n.dialogCancel,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.discographySelectedCount(
selectedCount,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
if (selectedCount > 0)
Text(
context.l10n.tracksCount(totalTracks),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
if (selectedCount > 0)
Text(
context.l10n.tracksCount(totalTracks),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: allSelected
? _deselectAll
: () => _selectAll(allAlbums),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: selectedCount > 0
? () => _downloadSelectedAlbums(
context,
selectedAlbums,
)
: null,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
context.l10n.discographyDownloadSelected,
),
),
),
),
],
),
],
)
: Row(
children: [
IconButton(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
tooltip: context.l10n.dialogCancel,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.discographySelectedCount(
selectedCount,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
if (selectedCount > 0)
Text(
context.l10n.tracksCount(totalTracks),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
TextButton(
onPressed: allSelected
? _deselectAll
: () => _selectAll(allAlbums),
child: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: selectedCount > 0
? () => _downloadSelectedAlbums(
context,
selectedAlbums,
)
: null,
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownloadSelected),
),
],
),
),
TextButton(
onPressed: allSelected
? _deselectAll
: () => _selectAll(allAlbums),
child: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: selectedCount > 0
? () => _downloadSelectedAlbums(context, selectedAlbums)
: null,
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownloadSelected),
),
],
),
),
),
),
@@ -1427,15 +1522,31 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
duration: const Duration(seconds: 2),
),
);
void enqueue(String service, {String? quality}) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
duration: const Duration(seconds: 2),
),
);
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
onSelect: (quality, service) {
if (!mounted) return;
enqueue(service, quality: quality);
},
);
return;
}
enqueue(settings.defaultService);
}
Widget _buildAlbumSection(
@@ -1468,7 +1579,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final album = albums[index];
return KeyedSubtree(
key: ValueKey(album.id),
child: _buildAlbumCard(album, colorScheme, tileSize: tileSize, sectionHeight: sectionHeight),
child: _buildAlbumCard(
album,
colorScheme,
tileSize: tileSize,
sectionHeight: sectionHeight,
),
);
},
),
@@ -1601,9 +1717,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Flexible(
child: Text(
album.name,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
+15 -1
View File
@@ -560,7 +560,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
// For lossy formats, use bitrate
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
for (final track in tracks) {
if (track.bitrate != firstBitrate) {
return null;
}
}
return '$fmt ${firstBitrate}kbps'.trim();
}
// For lossless formats, use bit depth / sample rate
if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null;
final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
+10 -2
View File
@@ -70,7 +70,12 @@ class UnifiedLibraryItem {
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
String? quality;
if (item.bitDepth != null && item.sampleRate != null) {
if (item.bitrate != null && item.bitrate! > 0) {
// Lossy format with bitrate
final fmt = item.format?.toUpperCase() ?? '';
quality = '$fmt ${item.bitrate}kbps'.trim();
} 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';
}
@@ -897,7 +902,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
String? _localQualityLabel(LocalLibraryItem item) {
if (item.bitDepth == null || item.sampleRate == null) {
if (item.bitrate != null && item.bitrate! > 0) {
return '${item.bitrate}kbps';
}
if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) {
return null;
}
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
+233 -123
View File
@@ -25,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
bool _artistFolderFiltersExpanded = false;
@override
void initState() {
@@ -363,7 +364,53 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value),
showDivider: false,
),
SettingsItem(
icon: Icons.filter_alt_outlined,
title: 'Artist Name Filters',
subtitle: _getArtistFolderFilterSubtitle(
context,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterAlbumArtistContributors:
settings.filterContributingArtistsInAlbumArtist,
),
trailing: Icon(
_artistFolderFiltersExpanded
? Icons.expand_less
: Icons.expand_more,
),
onTap: () {
setState(() {
_artistFolderFiltersExpanded =
!_artistFolderFiltersExpanded;
});
},
showDivider: !_artistFolderFiltersExpanded,
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.person_outline,
title: context.l10n.downloadUsePrimaryArtistOnly,
subtitle: settings.usePrimaryArtistOnly
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
value: settings.usePrimaryArtistOnly,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsePrimaryArtistOnly(value),
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.group_remove_outlined,
title: 'Filter contributing artists in Album Artist',
subtitle: settings.filterContributingArtistsInAlbumArtist
? 'Album Artist metadata uses primary artist only'
: 'Keep full Album Artist metadata value',
value: settings.filterContributingArtistsInAlbumArtist,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setFilterContributingArtistsInAlbumArtist(value),
showDivider: false,
),
SettingsSwitchItem(
icon: Icons.person_outline,
@@ -585,14 +632,28 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
final tags = [
final basicTags = [
'{artist}',
'{title}',
'{album}',
'{track}',
'{year}',
'{date}',
'{disc}',
];
final advancedTags = [
'{track_raw}',
'{track:02}',
'{track:1}',
'{date:%Y}',
'{date:%Y-%m-%d}',
'{disc_raw}',
'{disc:02}',
];
var showAdvancedTags = RegExp(
r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}',
caseSensitive: false,
).hasMatch(current);
void insertTag(String tag) {
final text = controller.text;
@@ -624,130 +685,164 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
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.filenameFormat,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Customize how your files are named.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: '{artist} - {title}',
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
autofocus: true,
),
const SizedBox(height: 24),
Text(
'Tap to insert tag:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: tags.map((tag) {
return ActionChip(
label: Text(tag),
onPressed: () => insertTag(tag),
backgroundColor: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
side: BorderSide.none,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => 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),
),
labelStyle: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
Text(
context.l10n.filenameFormat,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Customize how your files are named.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: '{artist} - {title}',
filled: true,
fillColor: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
);
}).toList(),
),
),
autofocus: true,
),
const SizedBox(height: 24),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
Text(
'Tap to insert tag:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: basicTags.map((tag) {
return ActionChip(
label: Text(tag),
onPressed: () => insertTag(tag),
backgroundColor: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
side: BorderSide.none,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Text(context.l10n.dialogCancel),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FilledButton(
onPressed: () {
ref
.read(settingsProvider.notifier)
.setFilenameFormat(controller.text);
Navigator.pop(context);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
labelStyle: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
child: Text(context.l10n.dialogSave),
),
);
}).toList(),
),
const SizedBox(height: 12),
SwitchListTile(
value: showAdvancedTags,
onChanged: (value) =>
setModalState(() => showAdvancedTags = value),
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.filenameShowAdvancedTags),
subtitle: Text(
context.l10n.filenameShowAdvancedTagsDescription,
),
),
if (showAdvancedTags) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: advancedTags.map((tag) {
return ActionChip(
label: Text(tag),
onPressed: () => insertTag(tag),
backgroundColor: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
side: BorderSide.none,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
labelStyle: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
);
}).toList(),
),
],
),
const SizedBox(height: 8),
],
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(context.l10n.dialogCancel),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FilledButton(
onPressed: () {
ref
.read(settingsProvider.notifier)
.setFilenameFormat(controller.text);
Navigator.pop(context);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(context.l10n.dialogSave),
),
),
],
),
const SizedBox(height: 8),
],
),
),
),
),
@@ -937,7 +1032,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
content: Text(
validation.errorReason ??
context.l10n.setupIcloudNotSupported,
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
@@ -1000,6 +1098,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
}
}
String _getArtistFolderFilterSubtitle(
BuildContext context, {
required bool usePrimaryArtistOnly,
required bool filterAlbumArtistContributors,
}) {
final statuses = <String>[
usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off',
filterAlbumArtistContributors
? 'Album Artist metadata: Primary only'
: 'Album Artist metadata: Full',
];
return statuses.join(' | ');
}
String _getLyricsModeLabel(BuildContext context, String mode) {
switch (mode) {
case 'external':
@@ -1456,9 +1568,7 @@ class _ServiceChip extends StatelessWidget {
return Expanded(
child: Material(
color: isSelected
? colorScheme.primaryContainer
: unselectedColor,
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
+11 -2
View File
@@ -68,10 +68,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _checkInitialPermissions() async {
if (Platform.isIOS) {
final notificationStatus = await Permission.notification.status;
if (mounted) {
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
_notificationPermissionGranted =
notificationStatus.isGranted || notificationStatus.isProvisional;
});
}
} else if (Platform.isAndroid) {
@@ -181,7 +183,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _requestNotificationPermission() async {
setState(() => _isLoading = true);
try {
if (_androidSdkVersion >= 33) {
if (Platform.isIOS) {
final status = await Permission.notification.request();
if (status.isGranted || status.isProvisional) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog('Notification');
}
} else if (_androidSdkVersion >= 33) {
final status = await Permission.notification.request();
if (status.isGranted) {
setState(() => _notificationPermissionGranted = true);
+94 -8
View File
@@ -416,6 +416,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
int? get sampleRate =>
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
@@ -424,8 +425,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt =>
_isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt;
DateTime get _addedAt {
if (_isLocalItem) {
// Use file modification time if available, otherwise fall back to scannedAt
final modTime = _localLibraryItem!.fileModTime;
if (modTime != null && modTime > 0) {
return DateTime.fromMillisecondsSinceEpoch(modTime);
}
return _localLibraryItem!.scannedAt;
}
return _downloadItem!.downloadedAt;
}
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
String get cleanFilePath {
@@ -433,6 +444,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return path.startsWith('EXISTS:') ? path.substring(7) : path;
}
String _formatPathForDisplay(String pathOrUri) {
if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) {
return pathOrUri;
}
try {
final uri = Uri.parse(pathOrUri);
final segments = uri.pathSegments;
String? documentId;
final documentIndex = segments.indexOf('document');
if (documentIndex != -1 && documentIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[documentIndex + 1]);
}
if (documentId == null || documentId.isEmpty) {
final treeIndex = segments.indexOf('tree');
if (treeIndex != -1 && treeIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[treeIndex + 1]);
}
}
if (documentId == null || documentId.isEmpty) return pathOrUri;
final separatorIndex = documentId.indexOf(':');
if (separatorIndex <= 0) return documentId;
final volumeId = documentId.substring(0, separatorIndex);
final relativePath = documentId
.substring(separatorIndex + 1)
.replaceAll('\\', '/');
if (volumeId.toLowerCase() == 'primary') {
if (relativePath.isEmpty) return '/storage/emulated/0';
return '/storage/emulated/0/$relativePath';
}
if (relativePath.isEmpty) return volumeId;
return 'SD Card/$relativePath';
} catch (_) {
return pathOrUri;
}
}
void _markMetadataChanged() {
_hasMetadataChanges = true;
}
@@ -913,7 +968,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 = _filePath.split('/').last;
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExt = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: '';
@@ -921,8 +976,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Use stored quality from download history if available
if (_quality != null && _quality!.isNotEmpty) {
audioQualityStr = _quality;
} else if (bitDepth != null && sampleRate != null) {
// Fallback for FLAC files without stored 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 {
@@ -1031,7 +1090,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool fileExists,
int? fileSize,
) {
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
final displayFilePath = _formatPathForDisplay(cleanFilePath);
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExtension = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: 'Unknown';
@@ -1128,7 +1188,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
)
else if (bitDepth != null && sampleRate != null)
else if (_isLocalItem &&
_localBitrate != null &&
_localBitrate! > 0 &&
(fileExtension == 'MP3' ||
fileExtension == 'OPUS' ||
fileExtension == 'OGG'))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_localBitrate}kbps',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
)
else if (bitDepth != null &&
bitDepth! > 0 &&
sampleRate != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
@@ -1194,7 +1280,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
children: [
Expanded(
child: Text(
cleanFilePath,
displayFilePath,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
+14 -1
View File
@@ -23,6 +23,7 @@ class LocalLibraryItem {
final String? releaseDate;
final int? bitDepth;
final int? sampleRate;
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
final String? genre;
final String? format; // flac, mp3, opus, m4a
@@ -43,6 +44,7 @@ class LocalLibraryItem {
this.releaseDate,
this.bitDepth,
this.sampleRate,
this.bitrate,
this.genre,
this.format,
});
@@ -64,6 +66,7 @@ class LocalLibraryItem {
'releaseDate': releaseDate,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
'bitrate': bitrate,
'genre': genre,
'format': format,
};
@@ -86,6 +89,7 @@ class LocalLibraryItem {
releaseDate: json['releaseDate'] as String?,
bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?,
bitrate: (json['bitrate'] as num?)?.toInt(),
genre: json['genre'] as String?,
format: json['format'] as String?,
);
@@ -115,7 +119,7 @@ class LibraryDatabase {
return await openDatabase(
path,
version: 3, // Bumped version for file_mod_time migration
version: 4, // Bumped version for bitrate column
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -142,6 +146,7 @@ class LibraryDatabase {
release_date TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
bitrate INTEGER,
genre TEXT,
format TEXT
)
@@ -169,6 +174,12 @@ class LibraryDatabase {
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
_log.i('Added file_mod_time column for incremental scanning');
}
if (oldVersion < 4) {
// Add bitrate column for lossy format quality info
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
_log.i('Added bitrate column for lossy format quality');
}
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
@@ -189,6 +200,7 @@ class LibraryDatabase {
'release_date': json['releaseDate'],
'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'],
'bitrate': json['bitrate'],
'genre': json['genre'],
'format': json['format'],
};
@@ -212,6 +224,7 @@ class LibraryDatabase {
'releaseDate': row['release_date'],
'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'],
'bitrate': row['bitrate'],
'genre': row['genre'],
'format': row['format'],
};
+276 -33
View File
@@ -1,27 +1,39 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
bool _notificationPermissionRequested = false;
static const int downloadProgressId = 1;
static const int updateDownloadId = 2;
static const int libraryScanId = 3;
static const String channelId = 'download_progress';
static const String channelName = 'Download Progress';
static const String channelDescription = 'Shows download progress for tracks';
static const String libraryChannelId = 'library_scan';
static const String libraryChannelName = 'Library Scan';
static const String libraryChannelDescription =
'Shows local library scan progress';
Future<void> initialize() async {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
@@ -33,24 +45,86 @@ class NotificationService {
await _notifications.initialize(settings: initSettings);
if (Platform.isAndroid) {
await _notifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(
const AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
final androidImpl = _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
await androidImpl?.createNotificationChannel(
const AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
await androidImpl?.createNotificationChannel(
const AndroidNotificationChannel(
libraryChannelId,
libraryChannelName,
description: libraryChannelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
}
_isInitialized = true;
}
Future<bool> _ensureNotificationPermission() async {
if (!Platform.isIOS) return true;
final status = await Permission.notification.status;
if (status.isGranted || status.isProvisional) return true;
if (_notificationPermissionRequested ||
status.isPermanentlyDenied ||
status.isRestricted) {
return false;
}
_notificationPermissionRequested = true;
final requested = await Permission.notification.request();
return requested.isGranted || requested.isProvisional;
}
Future<void> _showSafely({
required int id,
required String title,
required String body,
required NotificationDetails details,
}) async {
if (!await _ensureNotificationPermission()) return;
try {
await _notifications.show(
id: id,
title: title,
body: body,
notificationDetails: details,
);
} on PlatformException catch (e) {
final isNotificationsNotAllowed =
Platform.isIOS &&
(e.code == 'Error 1' ||
(e.message?.contains('UNErrorDomain error 1') ?? false) ||
e.toString().contains('UNErrorDomain error 1'));
if (isNotificationsNotAllowed) {
debugPrint(
'iOS notifications not allowed; skipping local notification',
);
return;
}
rethrow;
}
}
Future<void> showDownloadProgress({
required String trackName,
required String artistName,
@@ -60,7 +134,7 @@ class NotificationService {
if (!_isInitialized) await initialize();
final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
@@ -89,11 +163,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: 'Downloading $trackName',
body: '$artistName$percentage%',
notificationDetails: details,
details: details,
);
}
@@ -132,11 +206,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: 'Finalizing $trackName',
body: '$artistName • Embedding metadata...',
notificationDetails: details,
details: details,
);
}
@@ -182,11 +256,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: title,
body: '$trackName - $artistName',
notificationDetails: details,
details: details,
);
}
@@ -222,11 +296,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: title,
body: '$completedCount tracks downloaded successfully',
notificationDetails: details,
details: details,
);
}
@@ -234,6 +308,175 @@ class NotificationService {
await _notifications.cancel(id: downloadProgressId);
}
Future<void> showLibraryScanProgress({
required double progress,
required int scannedFiles,
required int totalFiles,
String? currentFile,
}) async {
if (!_isInitialized) await initialize();
final clampedProgress = progress.clamp(0.0, 100.0);
final percentage = clampedProgress.round();
final progressBody = totalFiles > 0
? '$scannedFiles/$totalFiles files • $percentage%'
: '$scannedFiles files scanned • $percentage%';
final body = (currentFile != null && currentFile.isNotEmpty)
? '$progressBody\n$currentFile'
: progressBody;
final androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: percentage,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Scanning local library',
body: body,
details: details,
);
}
Future<void> showLibraryScanComplete({
required int totalTracks,
int excludedDownloadedCount = 0,
int errorCount = 0,
}) async {
if (!_isInitialized) await initialize();
final extras = <String>[];
if (excludedDownloadedCount > 0) {
extras.add('$excludedDownloadedCount excluded');
}
if (errorCount > 0) {
extras.add('$errorCount errors');
}
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Library scan complete',
body: '$totalTracks tracks indexed$suffix',
details: details,
);
}
Future<void> showLibraryScanFailed(String message) async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Library scan failed',
body: message,
details: details,
);
}
Future<void> showLibraryScanCancelled() async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Library scan cancelled',
body: 'Scan stopped before completion.',
details: details,
);
}
Future<void> cancelLibraryScanNotification() async {
await _notifications.cancel(id: libraryScanId);
}
Future<void> showUpdateDownloadProgress({
required String version,
required int received,
@@ -244,7 +487,7 @@ class NotificationService {
final percentage = total > 0 ? (received * 100 ~/ total) : 0;
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
@@ -273,11 +516,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version',
body: '$receivedMB / $totalMB MB • $percentage%',
notificationDetails: details,
details: details,
);
}
@@ -306,11 +549,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: updateDownloadId,
title: 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.',
notificationDetails: details,
details: details,
);
}
@@ -338,11 +581,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: updateDownloadId,
title: 'Update Failed',
body: 'Could not download update. Try again later.',
notificationDetails: details,
details: details,
);
}
+1 -5
View File
@@ -103,8 +103,6 @@ class PlatformBridge {
return response;
}
static Future<Map<String, dynamic>> getDownloadProgress() async {
final result = await _channel.invokeMethod('getDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
@@ -509,6 +507,7 @@ class PlatformBridge {
return {
'genre': data['genre'] as String? ?? '',
'label': data['label'] as String? ?? '',
'copyright': data['copyright'] as String? ?? '',
};
} catch (e) {
_log.w('Failed to get Deezer extended metadata for $trackId: $e');
@@ -719,8 +718,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<void> cleanupExtensions() async {
_log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions');
@@ -1130,5 +1127,4 @@ class PlatformBridge {
}
// ==================== YOUTUBE / COBALT ====================
}
+76 -8
View File
@@ -12,6 +12,14 @@ final _iosContainerRootPattern = RegExp(
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
caseSensitive: false,
);
final _iosContainerPathWithoutLeadingSlashPattern = RegExp(
r'^(private/)?var/mobile/Containers/Data/Application/[A-F0-9\-]+/.+',
caseSensitive: false,
);
final _iosLegacyRelativeDocumentsPattern = RegExp(
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
caseSensitive: false,
);
/// Checks if a path is a valid writable directory on iOS.
/// Returns false if:
@@ -21,6 +29,7 @@ final _iosContainerRootPattern = RegExp(
bool isValidIosWritablePath(String path) {
if (!Platform.isIOS) return true;
if (path.isEmpty) return false;
if (!path.startsWith('/')) return false;
// Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) {
@@ -54,16 +63,64 @@ bool isValidIosWritablePath(String path) {
/// Validates and potentially corrects an iOS path.
/// Returns a valid Documents subdirectory path if the input is invalid.
Future<String> validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async {
Future<String> validateOrFixIosPath(
String path, {
String subfolder = 'SpotiFLAC',
}) async {
if (!Platform.isIOS) return path;
if (isValidIosWritablePath(path)) {
return path;
final trimmed = path.trim();
if (isValidIosWritablePath(trimmed)) {
return trimmed;
}
final docDir = await getApplicationDocumentsDirectory();
final candidates = <String>[];
if (trimmed.isNotEmpty) {
candidates.add(trimmed);
}
// Some pickers can return absolute iOS paths without the leading slash.
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
candidates.add('/$trimmed');
}
// Recover legacy relative iOS path format:
// Data/Application/<UUID>/Documents/<subdir>
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
trimmed,
);
if (legacyRelativeMatch != null) {
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
final normalizedSuffix = suffix.startsWith('/')
? suffix.substring(1)
: suffix;
candidates.add(
normalizedSuffix.isEmpty
? docDir.path
: '${docDir.path}/$normalizedSuffix',
);
}
// Generic salvage for relative paths containing `Documents/...`.
if (!trimmed.startsWith('/')) {
final documentsMarker = 'Documents/';
final index = trimmed.indexOf(documentsMarker);
if (index >= 0) {
final suffix = trimmed.substring(index + documentsMarker.length).trim();
candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix');
}
}
for (final candidate in candidates) {
if (isValidIosWritablePath(candidate)) {
return candidate;
}
}
// Fall back to app Documents directory
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/$subfolder');
final musicDir = Directory('${docDir.path}/$subfolder');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
@@ -96,11 +153,20 @@ IosPathValidationResult validateIosPath(String path) {
);
}
if (!path.startsWith('/')) {
return const IosPathValidationResult(
isValid: false,
errorReason:
'Invalid path format. Please choose a local folder from Files.',
);
}
// Check if it's the container root
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.',
errorReason:
'Cannot write to app container root. Please choose a subfolder like Documents.',
);
}
@@ -110,7 +176,8 @@ IosPathValidationResult validateIosPath(String path) {
path.contains('com~apple~CloudDocs')) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'iCloud Drive is not supported. Please choose a local folder.',
errorReason:
'iCloud Drive is not supported. Please choose a local folder.',
);
}
@@ -125,7 +192,8 @@ IosPathValidationResult validateIosPath(String path) {
if (remainingPath.isEmpty || remainingPath == '/') {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.',
errorReason:
'Cannot write to app container root. Please use the default folder or choose a different location.',
);
}
}