mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 20:57:57 +02:00
Merge dev into main: v3.6.7 release
This commit is contained in:
+19
-15
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+246
-232
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'フォルダ構成';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+3733
-2140
File diff suppressed because it is too large
Load Diff
+1246
-234
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+1107
-95
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+856
-221
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1041
-29
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1316
-51
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+1028
-16
File diff suppressed because it is too large
Load Diff
+55
-5
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user