From 0ccda8db580bce371eefd6a945858137927eb81f Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 03:27:43 +0700 Subject: [PATCH 01/48] fix: locale format and translation updates --- lib/l10n/app_localizations_de.dart | 224 ++--- lib/l10n/app_localizations_ja.dart | 254 +++--- lib/l10n/app_localizations_ru.dart | 1213 +++++++++++++++------------- lib/l10n/app_localizations_zh.dart | 69 ++ lib/l10n/arb/app_zh_CN.arb | 2 +- lib/l10n/arb/app_zh_TW.arb | 2 +- 6 files changed, 944 insertions(+), 820 deletions(-) diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index cb3b2481..b4a9ff7e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -13,56 +13,57 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Laden Sie Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; @override - String get navHome => 'Home'; + String get navHome => 'Startseite'; @override - String get navHistory => 'History'; + String get navHistory => 'Verlauf'; @override - String get navSettings => 'Settings'; + String get navSettings => 'Einstellungen'; @override String get navStore => 'Store'; @override - String get homeTitle => 'Home'; + String get homeTitle => 'Startseite'; @override - String get homeSearchHint => 'Paste Spotify URL or search...'; + String get homeSearchHint => 'Spotify-URL einfügen oder suchen...'; @override String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; + return 'Mit $extensionName suchen...'; } @override - String get homeSubtitle => 'Paste a Spotify link or search by name'; + String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen'; @override - String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; + String get homeSupports => + 'Unterstützt: Titel, Album, Playlist, Künstler-URLs'; @override - String get homeRecent => 'Recent'; + String get homeRecent => 'Zuletzt'; @override - String get historyTitle => 'History'; + String get historyTitle => 'Verlauf'; @override String historyDownloading(int count) { - return 'Downloading ($count)'; + return 'Wird heruntergeladen ($count)'; } @override - String get historyDownloaded => 'Downloaded'; + String get historyDownloaded => 'Heruntergeladen'; @override - String get historyFilterAll => 'All'; + String get historyFilterAll => 'Alle'; @override - String get historyFilterAlbums => 'Albums'; + String get historyFilterAlbums => 'Alben'; @override String get historyFilterSingles => 'Singles'; @@ -72,8 +73,8 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count Titel', + one: '1 Titel', ); return '$_temp0'; } @@ -83,93 +84,95 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count albums', - one: '1 album', + other: '$count Alben', + one: '1 Album', ); return '$_temp0'; } @override - String get historyNoDownloads => 'No download history'; + String get historyNoDownloads => 'Kein Download-Verlauf'; @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; + String get historyNoDownloadsSubtitle => + 'Heruntergeladene Titel werden hier angezeigt'; @override - String get historyNoAlbums => 'No album downloads'; + String get historyNoAlbums => 'Keine Album-Downloads'; @override String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; + 'Laden Sie mehrere Titel eines Albums herunter, um sie hier zu sehen'; @override - String get historyNoSingles => 'No single downloads'; + String get historyNoSingles => 'Keine Einzel-Downloads'; @override String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; + 'Einzelne Titel-Downloads werden hier angezeigt'; @override - String get settingsTitle => 'Settings'; + String get settingsTitle => 'Einstellungen'; @override String get settingsDownload => 'Download'; @override - String get settingsAppearance => 'Appearance'; + String get settingsAppearance => 'Erscheinungsbild'; @override - String get settingsOptions => 'Options'; + String get settingsOptions => 'Optionen'; @override - String get settingsExtensions => 'Extensions'; + String get settingsExtensions => 'Erweiterungen'; @override - String get settingsAbout => 'About'; + String get settingsAbout => 'Über'; @override String get downloadTitle => 'Download'; @override - String get downloadLocation => 'Download Location'; + String get downloadLocation => 'Download-Speicherort'; @override - String get downloadLocationSubtitle => 'Choose where to save files'; + String get downloadLocationSubtitle => + 'Wählen Sie den Speicherort für Dateien'; @override - String get downloadLocationDefault => 'Default location'; + String get downloadLocationDefault => 'Standard-Speicherort'; @override - String get downloadDefaultService => 'Default Service'; + String get downloadDefaultService => 'Standard-Dienst'; @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; + String get downloadDefaultServiceSubtitle => 'Dienst für Downloads'; @override - String get downloadDefaultQuality => 'Default Quality'; + String get downloadDefaultQuality => 'Standard-Qualität'; @override - String get downloadAskQuality => 'Ask Quality Before Download'; + String get downloadAskQuality => 'Qualität vor Download abfragen'; @override String get downloadAskQualitySubtitle => - 'Show quality picker for each download'; + 'Qualitätsauswahl für jeden Download anzeigen'; @override - String get downloadFilenameFormat => 'Filename Format'; + String get downloadFilenameFormat => 'Dateinamenformat'; @override - String get downloadFolderOrganization => 'Folder Organization'; + String get downloadFolderOrganization => 'Ordnerstruktur'; @override - String get downloadSeparateSingles => 'Separate Singles'; + String get downloadSeparateSingles => 'Singles trennen'; @override String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; + 'Einzelne Titel in separatem Ordner speichern'; @override - String get qualityBest => 'Best Available'; + String get qualityBest => 'Beste Qualität'; @override String get qualityFlac => 'FLAC'; @@ -181,179 +184,186 @@ class AppLocalizationsDe extends AppLocalizations { String get quality128 => '128 kbps'; @override - String get appearanceTitle => 'Appearance'; + String get appearanceTitle => 'Erscheinungsbild'; @override - String get appearanceTheme => 'Theme'; + String get appearanceTheme => 'Design'; @override String get appearanceThemeSystem => 'System'; @override - String get appearanceThemeLight => 'Light'; + String get appearanceThemeLight => 'Hell'; @override - String get appearanceThemeDark => 'Dark'; + String get appearanceThemeDark => 'Dunkel'; @override - String get appearanceDynamicColor => 'Dynamic Color'; + String get appearanceDynamicColor => 'Dynamische Farben'; @override - String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + String get appearanceDynamicColorSubtitle => + 'Farben von Ihrem Hintergrundbild verwenden'; @override - String get appearanceAccentColor => 'Accent Color'; + String get appearanceAccentColor => 'Akzentfarbe'; @override - String get appearanceHistoryView => 'History View'; + String get appearanceHistoryView => 'Verlaufsansicht'; @override - String get appearanceHistoryViewList => 'List'; + String get appearanceHistoryViewList => 'Liste'; @override - String get appearanceHistoryViewGrid => 'Grid'; + String get appearanceHistoryViewGrid => 'Raster'; @override - String get optionsTitle => 'Options'; + String get optionsTitle => 'Optionen'; @override - String get optionsSearchSource => 'Search Source'; + String get optionsSearchSource => 'Suchquelle'; @override - String get optionsPrimaryProvider => 'Primary Provider'; + String get optionsPrimaryProvider => 'Primärer Anbieter'; @override String get optionsPrimaryProviderSubtitle => - 'Service used when searching by track name.'; + 'Dienst für die Suche nach Titelnamen.'; @override String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; + return 'Erweiterung verwenden: $extensionName'; } @override String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; + 'Tippen Sie auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln'; @override - String get optionsAutoFallback => 'Auto Fallback'; + String get optionsAutoFallback => 'Automatischer Fallback'; @override String get optionsAutoFallbackSubtitle => - 'Try other services if download fails'; + 'Andere Dienste versuchen, wenn Download fehlschlägt'; @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; + String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden'; @override - String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; + String get optionsUseExtensionProvidersOn => + 'Erweiterungen werden zuerst versucht'; @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + String get optionsUseExtensionProvidersOff => + 'Nur integrierte Anbieter verwenden'; @override - String get optionsEmbedLyrics => 'Embed Lyrics'; + String get optionsEmbedLyrics => 'Liedtexte einbetten'; @override String get optionsEmbedLyricsSubtitle => - 'Embed synced lyrics into FLAC files'; + 'Synchronisierte Liedtexte in FLAC-Dateien einbetten'; @override - String get optionsMaxQualityCover => 'Max Quality Cover'; + String get optionsMaxQualityCover => 'Maximale Cover-Qualität'; @override String get optionsMaxQualityCoverSubtitle => - 'Download highest resolution cover art'; + 'Cover in höchster Auflösung herunterladen'; @override - String get optionsConcurrentDownloads => 'Concurrent Downloads'; + String get optionsConcurrentDownloads => 'Parallele Downloads'; @override - String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + String get optionsConcurrentSequential => 'Sequentiell (1 gleichzeitig)'; @override String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; + return '$count parallele Downloads'; } @override String get optionsConcurrentWarning => - 'Parallel downloads may trigger rate limiting'; + 'Parallele Downloads können Ratenlimitierung auslösen'; @override - String get optionsExtensionStore => 'Extension Store'; + String get optionsExtensionStore => 'Erweiterungs-Store'; @override - String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + String get optionsExtensionStoreSubtitle => + 'Store-Tab in Navigation anzeigen'; @override - String get optionsCheckUpdates => 'Check for Updates'; + String get optionsCheckUpdates => 'Nach Updates suchen'; @override String get optionsCheckUpdatesSubtitle => - 'Notify when new version is available'; + 'Benachrichtigen, wenn neue Version verfügbar'; @override - String get optionsUpdateChannel => 'Update Channel'; + String get optionsUpdateChannel => 'Update-Kanal'; @override - String get optionsUpdateChannelStable => 'Stable releases only'; + String get optionsUpdateChannelStable => 'Nur stabile Versionen'; @override - String get optionsUpdateChannelPreview => 'Get preview releases'; + String get optionsUpdateChannelPreview => 'Vorschau-Versionen erhalten'; @override String get optionsUpdateChannelWarning => - 'Preview may contain bugs or incomplete features'; + 'Vorschau kann Fehler oder unvollständige Funktionen enthalten'; @override - String get optionsClearHistory => 'Clear Download History'; + String get optionsClearHistory => 'Download-Verlauf löschen'; @override String get optionsClearHistorySubtitle => - 'Remove all downloaded tracks from history'; + 'Alle heruntergeladenen Titel aus dem Verlauf entfernen'; @override - String get optionsDetailedLogging => 'Detailed Logging'; + String get optionsDetailedLogging => 'Detaillierte Protokollierung'; @override - String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + String get optionsDetailedLoggingOn => + 'Detaillierte Protokolle werden aufgezeichnet'; @override - String get optionsDetailedLoggingOff => 'Enable for bug reports'; + String get optionsDetailedLoggingOff => 'Für Fehlerberichte aktivieren'; @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; + String get optionsSpotifyCredentials => 'Spotify-Anmeldedaten'; @override String optionsSpotifyCredentialsConfigured(String clientId) { - return 'Client ID: $clientId...'; + return 'Client-ID: $clientId...'; } @override - String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + String get optionsSpotifyCredentialsRequired => + 'Erforderlich - zum Konfigurieren tippen'; @override String get optionsSpotifyWarning => - 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + 'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com'; @override - String get extensionsTitle => 'Extensions'; + String get extensionsTitle => 'Erweiterungen'; @override - String get extensionsInstalled => 'Installed Extensions'; + String get extensionsInstalled => 'Installierte Erweiterungen'; @override - String get extensionsNone => 'No extensions installed'; + String get extensionsNone => 'Keine Erweiterungen installiert'; @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; + String get extensionsNoneSubtitle => + 'Erweiterungen aus dem Store-Tab installieren'; @override - String get extensionsEnabled => 'Enabled'; + String get extensionsEnabled => 'Aktiviert'; @override - String get extensionsDisabled => 'Disabled'; + String get extensionsDisabled => 'Deaktiviert'; @override String extensionsVersion(String version) { @@ -362,41 +372,41 @@ class AppLocalizationsDe extends AppLocalizations { @override String extensionsAuthor(String author) { - return 'by $author'; + return 'von $author'; } @override - String get extensionsUninstall => 'Uninstall'; + String get extensionsUninstall => 'Deinstallieren'; @override - String get extensionsSetAsSearch => 'Set as Search Provider'; + String get extensionsSetAsSearch => 'Als Suchanbieter festlegen'; @override - String get storeTitle => 'Extension Store'; + String get storeTitle => 'Erweiterungs-Store'; @override - String get storeSearch => 'Search extensions...'; + String get storeSearch => 'Erweiterungen suchen...'; @override - String get storeInstall => 'Install'; + String get storeInstall => 'Installieren'; @override - String get storeInstalled => 'Installed'; + String get storeInstalled => 'Installiert'; @override - String get storeUpdate => 'Update'; + String get storeUpdate => 'Aktualisieren'; @override - String get aboutTitle => 'About'; + String get aboutTitle => 'Über'; @override - String get aboutContributors => 'Contributors'; + String get aboutContributors => 'Mitwirkende'; @override - String get aboutMobileDeveloper => 'Mobile version developer'; + String get aboutMobileDeveloper => 'Mobile-Version Entwickler'; @override - String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + String get aboutOriginalCreator => 'Schöpfer des ursprünglichen SpotiFLAC'; @override String get aboutLogoArtist => diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index dc159b26..1ff5c936 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -16,19 +16,19 @@ class AppLocalizationsJa extends AppLocalizations { 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; @override - String get navHome => 'Home'; + String get navHome => 'ホーム'; @override - String get navHistory => 'History'; + String get navHistory => '履歴'; @override - String get navSettings => 'Settings'; + String get navSettings => '設定'; @override - String get navStore => 'Store'; + String get navStore => 'ストア'; @override - String get homeTitle => 'Home'; + String get homeTitle => 'ホーム'; @override String get homeSearchHint => 'Paste Spotify URL or search...'; @@ -52,20 +52,20 @@ class AppLocalizationsJa extends AppLocalizations { @override String historyDownloading(int count) { - return 'Downloading ($count)'; + return 'ダウンロード中 ($count)'; } @override - String get historyDownloaded => 'Downloaded'; + String get historyDownloaded => 'ダウンロード済み'; @override - String get historyFilterAll => 'All'; + String get historyFilterAll => 'すべて'; @override - String get historyFilterAlbums => 'Albums'; + String get historyFilterAlbums => 'アルバム'; @override - String get historyFilterSingles => 'Singles'; + String get historyFilterSingles => 'シングル'; @override String historyTracksCount(int count) { @@ -110,25 +110,25 @@ class AppLocalizationsJa extends AppLocalizations { 'Single track downloads will appear here'; @override - String get settingsTitle => 'Settings'; + String get settingsTitle => '設定'; @override - String get settingsDownload => 'Download'; + String get settingsDownload => 'ダウンロード'; @override - String get settingsAppearance => 'Appearance'; + String get settingsAppearance => '外観'; @override - String get settingsOptions => 'Options'; + String get settingsOptions => 'オプション'; @override - String get settingsExtensions => 'Extensions'; + String get settingsExtensions => '拡張'; @override - String get settingsAbout => 'About'; + String get settingsAbout => 'アプリについて'; @override - String get downloadTitle => 'Download'; + String get downloadTitle => 'ダウンロード'; @override String get downloadLocation => 'Download Location'; @@ -137,16 +137,16 @@ class AppLocalizationsJa extends AppLocalizations { String get downloadLocationSubtitle => 'Choose where to save files'; @override - String get downloadLocationDefault => 'Default location'; + String get downloadLocationDefault => 'デフォルトの場所'; @override - String get downloadDefaultService => 'Default Service'; + String get downloadDefaultService => 'デフォルトのサービス'; @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; + String get downloadDefaultServiceSubtitle => 'ダウンロードに使用したサービス'; @override - String get downloadDefaultQuality => 'Default Quality'; + String get downloadDefaultQuality => 'デフォルトの品質'; @override String get downloadAskQuality => 'Ask Quality Before Download'; @@ -156,7 +156,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Show quality picker for each download'; @override - String get downloadFilenameFormat => 'Filename Format'; + String get downloadFilenameFormat => 'ファイル名の形式'; @override String get downloadFolderOrganization => 'Folder Organization'; @@ -181,46 +181,46 @@ class AppLocalizationsJa extends AppLocalizations { String get quality128 => '128 kbps'; @override - String get appearanceTitle => 'Appearance'; + String get appearanceTitle => '外観'; @override - String get appearanceTheme => 'Theme'; + String get appearanceTheme => 'テーマ'; @override - String get appearanceThemeSystem => 'System'; + String get appearanceThemeSystem => 'システム'; @override - String get appearanceThemeLight => 'Light'; + String get appearanceThemeLight => 'ライト'; @override - String get appearanceThemeDark => 'Dark'; + String get appearanceThemeDark => 'ダーク'; @override - String get appearanceDynamicColor => 'Dynamic Color'; + String get appearanceDynamicColor => 'ダイナミックカラー'; @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; @override - String get appearanceAccentColor => 'Accent Color'; + String get appearanceAccentColor => 'アクセントカラー'; @override - String get appearanceHistoryView => 'History View'; + String get appearanceHistoryView => '履歴の表示'; @override - String get appearanceHistoryViewList => 'List'; + String get appearanceHistoryViewList => 'リスト'; @override - String get appearanceHistoryViewGrid => 'Grid'; + String get appearanceHistoryViewGrid => 'グリッド'; @override - String get optionsTitle => 'Options'; + String get optionsTitle => 'オプション'; @override - String get optionsSearchSource => 'Search Source'; + String get optionsSearchSource => '検索ソース'; @override - String get optionsPrimaryProvider => 'Primary Provider'; + String get optionsPrimaryProvider => 'プライマリーのプロバイダー'; @override String get optionsPrimaryProviderSubtitle => @@ -228,7 +228,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; + return '拡張の使用: $extensionName'; } @override @@ -243,23 +243,23 @@ class AppLocalizationsJa extends AppLocalizations { 'Try other services if download fails'; @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; + String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する'; @override String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + String get optionsUseExtensionProvidersOff => '内蔵のプロバイダーのみを使用する'; @override - String get optionsEmbedLyrics => 'Embed Lyrics'; + String get optionsEmbedLyrics => '歌詞を埋め込む'; @override String get optionsEmbedLyricsSubtitle => 'Embed synced lyrics into FLAC files'; @override - String get optionsMaxQualityCover => 'Max Quality Cover'; + String get optionsMaxQualityCover => '最大品質のカバー'; @override String get optionsMaxQualityCoverSubtitle => @@ -281,26 +281,26 @@ class AppLocalizationsJa extends AppLocalizations { 'Parallel downloads may trigger rate limiting'; @override - String get optionsExtensionStore => 'Extension Store'; + String get optionsExtensionStore => '拡張ストア'; @override String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; @override - String get optionsCheckUpdates => 'Check for Updates'; + String get optionsCheckUpdates => '更新を確認'; @override String get optionsCheckUpdatesSubtitle => 'Notify when new version is available'; @override - String get optionsUpdateChannel => 'Update Channel'; + String get optionsUpdateChannel => '更新チャンネル'; @override - String get optionsUpdateChannelStable => 'Stable releases only'; + String get optionsUpdateChannelStable => '安定版リリースのみ'; @override - String get optionsUpdateChannelPreview => 'Get preview releases'; + String get optionsUpdateChannelPreview => 'プレビューリリースを入手'; @override String get optionsUpdateChannelWarning => @@ -323,11 +323,11 @@ class AppLocalizationsJa extends AppLocalizations { String get optionsDetailedLoggingOff => 'Enable for bug reports'; @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; + String get optionsSpotifyCredentials => 'Spotify の認証情報'; @override String optionsSpotifyCredentialsConfigured(String clientId) { - return 'Client ID: $clientId...'; + return 'クライアント ID: $clientId...'; } @override @@ -338,62 +338,62 @@ class AppLocalizationsJa extends AppLocalizations { 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; @override - String get extensionsTitle => 'Extensions'; + String get extensionsTitle => '拡張'; @override - String get extensionsInstalled => 'Installed Extensions'; + String get extensionsInstalled => 'インストール済みの拡張'; @override - String get extensionsNone => 'No extensions installed'; + String get extensionsNone => '拡張はインストールされていません'; @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; + String get extensionsNoneSubtitle => 'ストアタブから拡張をインストール'; @override - String get extensionsEnabled => 'Enabled'; + String get extensionsEnabled => '有効'; @override String get extensionsDisabled => 'Disabled'; @override String extensionsVersion(String version) { - return 'Version $version'; + return 'バージョン $version'; } @override String extensionsAuthor(String author) { - return 'by $author'; + return '作者 $author'; } @override - String get extensionsUninstall => 'Uninstall'; + String get extensionsUninstall => 'アンインストール'; @override - String get extensionsSetAsSearch => 'Set as Search Provider'; + String get extensionsSetAsSearch => '検索プロバイダーを設定'; @override - String get storeTitle => 'Extension Store'; + String get storeTitle => '拡張ストア'; @override - String get storeSearch => 'Search extensions...'; + String get storeSearch => '拡張を検索...'; @override - String get storeInstall => 'Install'; + String get storeInstall => 'インストール'; @override - String get storeInstalled => 'Installed'; + String get storeInstalled => 'インストール済み'; @override - String get storeUpdate => 'Update'; + String get storeUpdate => '更新'; @override - String get aboutTitle => 'About'; + String get aboutTitle => 'アプリについて'; @override - String get aboutContributors => 'Contributors'; + String get aboutContributors => '貢献者'; @override - String get aboutMobileDeveloper => 'Mobile version developer'; + String get aboutMobileDeveloper => 'モバイルバージョンの開発者'; @override String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; @@ -403,25 +403,25 @@ class AppLocalizationsJa extends AppLocalizations { 'The talented artist who created our beautiful app logo!'; @override - String get aboutSpecialThanks => 'Special Thanks'; + String get aboutSpecialThanks => 'スペシャルサンクス'; @override - String get aboutLinks => 'Links'; + String get aboutLinks => 'リンク'; @override - String get aboutMobileSource => 'Mobile source code'; + String get aboutMobileSource => 'モバイル版のソースコード'; @override - String get aboutPCSource => 'PC source code'; + String get aboutPCSource => 'PC 版のソースコード'; @override - String get aboutReportIssue => 'Report an issue'; + String get aboutReportIssue => 'Issue で報告する'; @override String get aboutReportIssueSubtitle => 'Report any problems you encounter'; @override - String get aboutFeatureRequest => 'Feature request'; + String get aboutFeatureRequest => '機能の要望'; @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; @@ -430,16 +430,16 @@ class AppLocalizationsJa extends AppLocalizations { String get aboutSupport => 'Support'; @override - String get aboutBuyMeCoffee => 'Buy me a coffee'; + String get aboutBuyMeCoffee => 'コーヒーを買ってください'; @override - String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; + String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします'; @override - String get aboutApp => 'App'; + String get aboutApp => 'アプリ'; @override - String get aboutVersion => 'Version'; + String get aboutVersion => 'バージョン'; @override String get aboutBinimumDesc => @@ -497,10 +497,10 @@ class AppLocalizationsJa extends AppLocalizations { String get artistAlbums => 'Albums'; @override - String get artistSingles => 'Singles & EPs'; + String get artistSingles => 'シングルと EP'; @override - String get artistCompilations => 'Compilations'; + String get artistCompilations => 'コンピレーション'; @override String artistReleases(int count) { @@ -589,13 +589,13 @@ class AppLocalizationsJa extends AppLocalizations { String get setupChooseFolder => 'Choose Folder'; @override - String get setupContinue => 'Continue'; + String get setupContinue => '続行'; @override - String get setupSkip => 'Skip for now'; + String get setupSkip => '今はスキップ'; @override - String get setupStorageAccessRequired => 'Storage Access Required'; + String get setupStorageAccessRequired => 'ストレージアクセスが必要です'; @override String get setupStorageAccessMessage => @@ -675,7 +675,7 @@ class AppLocalizationsJa extends AppLocalizations { String get setupStepSpotify => 'Spotify'; @override - String get setupStepPermission => 'Permission'; + String get setupStepPermission => '権限'; @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -691,14 +691,14 @@ class AppLocalizationsJa extends AppLocalizations { String get setupNotificationGranted => 'Notification Permission Granted!'; @override - String get setupNotificationEnable => 'Enable Notifications'; + String get setupNotificationEnable => '通知を有効化する'; @override String get setupNotificationDescription => 'Get notified when downloads complete or require attention.'; @override - String get setupFolderSelected => 'Download Folder Selected!'; + String get setupFolderSelected => 'ダウンロードフォルダが選択済みです!'; @override String get setupFolderChoose => 'Choose Download Folder'; @@ -714,26 +714,26 @@ class AppLocalizationsJa extends AppLocalizations { String get setupSelectFolder => 'Select Folder'; @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; + String get setupSpotifyApiOptional => 'Spotify API (任意)'; @override String get setupSpotifyApiDescription => 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; @override - String get setupUseSpotifyApi => 'Use Spotify API'; + String get setupUseSpotifyApi => 'Spotify API を使用する'; @override String get setupEnterCredentialsBelow => 'Enter your credentials below'; @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; + String get setupUsingDeezer => 'Deezer を使用中 (アカウントは不要です)'; @override - String get setupEnterClientId => 'Enter Spotify Client ID'; + String get setupEnterClientId => 'Spotify クライアント ID を入力'; @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; + String get setupEnterClientSecret => 'Spotify クライアントシークレットを入力'; @override String get setupGetFreeCredentials => @@ -754,19 +754,19 @@ class AppLocalizationsJa extends AppLocalizations { 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @override - String get setupSkipForNow => 'Skip for now'; + String get setupSkipForNow => '今はスキップ'; @override - String get setupBack => 'Back'; + String get setupBack => '戻る'; @override - String get setupNext => 'Next'; + String get setupNext => '次へ'; @override String get setupGetStarted => 'Get Started'; @override - String get setupSkipAndStart => 'Skip & Start'; + String get setupSkipAndStart => 'スキップと開始'; @override String get setupAllowAccessToManageFiles => @@ -858,7 +858,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Are you sure you want to remove this extension? This cannot be undone.'; @override - String get dialogUninstallExtension => 'Uninstall Extension?'; + String get dialogUninstallExtension => '拡張をアンインストールしますか?'; @override String dialogUninstallExtensionMessage(String extensionName) { @@ -887,7 +887,7 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get dialogImportPlaylistTitle => 'Import Playlist'; + String get dialogImportPlaylistTitle => 'プレイリストをインポート'; @override String dialogImportPlaylistMessage(int count) { @@ -980,7 +980,7 @@ class AppLocalizationsJa extends AppLocalizations { String get snackbarFailedToUpdate => 'Failed to update extension'; @override - String get errorRateLimited => 'Rate Limited'; + String get errorRateLimited => 'レート制限'; @override String get errorRateLimitedMessage => @@ -1178,7 +1178,7 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get updateDownload => 'Download'; + String get updateDownload => 'ダウンロード'; @override String get updateLater => 'Later'; @@ -1199,7 +1199,7 @@ class AppLocalizationsJa extends AppLocalizations { String get updateNewVersionReady => 'A new version is ready'; @override - String get updateCurrent => 'Current'; + String get updateCurrent => '現在'; @override String get updateNew => 'New'; @@ -1303,13 +1303,13 @@ class AppLocalizationsJa extends AppLocalizations { String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; + String get logIspBlocking => 'ISP のブロックを検出しました'; @override - String get logRateLimited => 'RATE LIMITED'; + String get logRateLimited => 'レート制限'; @override - String get logNetworkError => 'NETWORK ERROR'; + String get logNetworkError => 'ネットワークエラー'; @override String get logTrackNotFound => 'TRACK NOT FOUND'; @@ -1498,22 +1498,22 @@ class AppLocalizationsJa extends AppLocalizations { String get trackMetadata => 'Metadata'; @override - String get trackFileInfo => 'File Info'; + String get trackFileInfo => 'ファイル情報'; @override - String get trackLyrics => 'Lyrics'; + String get trackLyrics => '歌詞'; @override - String get trackFileNotFound => 'File not found'; + String get trackFileNotFound => 'ファイルがありません'; @override - String get trackOpenInDeezer => 'Open in Deezer'; + String get trackOpenInDeezer => 'Deezer で開く'; @override - String get trackOpenInSpotify => 'Open in Spotify'; + String get trackOpenInSpotify => 'Spotify で開く'; @override - String get trackTrackName => 'Track name'; + String get trackTrackName => 'トラック名'; @override String get trackArtist => 'Artist'; @@ -1636,16 +1636,16 @@ class AppLocalizationsJa extends AppLocalizations { String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @override - String get extensionDefaultProviderSubtitle => 'Use built-in search'; + String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する'; @override - String get extensionAuthor => 'Author'; + String get extensionAuthor => '作者'; @override String get extensionId => 'ID'; @override - String get extensionError => 'Error'; + String get extensionError => 'エラー'; @override String get extensionCapabilities => 'Capabilities'; @@ -1675,16 +1675,16 @@ class AppLocalizationsJa extends AppLocalizations { String get extensionSettings => 'Settings'; @override - String get extensionRemoveButton => 'Remove Extension'; + String get extensionRemoveButton => '拡張を削除'; @override - String get extensionUpdated => 'Updated'; + String get extensionUpdated => '更新済み'; @override - String get extensionMinAppVersion => 'Min App Version'; + String get extensionMinAppVersion => '最小のアプリバージョン'; @override - String get extensionCustomTrackMatching => 'Custom Track Matching'; + String get extensionCustomTrackMatching => 'カスタムトラックマッチング'; @override String get extensionPostProcessing => 'Post-Processing'; @@ -1708,17 +1708,17 @@ class AppLocalizationsJa extends AppLocalizations { String get extensionsProviderPrioritySection => 'Provider Priority'; @override - String get extensionsInstalledSection => 'Installed Extensions'; + String get extensionsInstalledSection => 'インストール済みの拡張'; @override - String get extensionsNoExtensions => 'No extensions installed'; + String get extensionsNoExtensions => '拡張はインストールされていません'; @override String get extensionsNoExtensionsSubtitle => 'Install .spotiflac-ext files to add new providers'; @override - String get extensionsInstallButton => 'Install Extension'; + String get extensionsInstallButton => '拡張をインストール'; @override String get extensionsInfoTip => @@ -1765,22 +1765,22 @@ class AppLocalizationsJa extends AppLocalizations { String get extensionsErrorLoading => 'Error loading extension'; @override - String get qualityFlacLossless => 'FLAC Lossless'; + String get qualityFlacLossless => 'FLAC ロスレス'; @override String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; @override - String get qualityHiResFlac => 'Hi-Res FLAC'; + String get qualityHiResFlac => 'ハイレゾ FLAC'; @override - String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + String get qualityHiResFlacSubtitle => '24-bit / 最大 96kHz'; @override - String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + String get qualityHiResFlacMax => 'ハイレゾ FLAC 最大'; @override - String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz'; @override String get qualityNote => @@ -1790,10 +1790,10 @@ class AppLocalizationsJa extends AppLocalizations { String get downloadAskBeforeDownload => 'Ask Before Download'; @override - String get downloadDirectory => 'Download Directory'; + String get downloadDirectory => 'ダウンロードディレクトリ'; @override - String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + String get downloadSeparateSinglesFolder => 'シングルのフォルダを分割'; @override String get downloadAlbumFolderStructure => 'Album Folder Structure'; @@ -1856,22 +1856,22 @@ class AppLocalizationsJa extends AppLocalizations { String get serviceSpotify => 'Spotify'; @override - String get appearanceAmoledDark => 'AMOLED Dark'; + String get appearanceAmoledDark => 'AMOLED ダーク'; @override - String get appearanceAmoledDarkSubtitle => 'Pure black background'; + String get appearanceAmoledDarkSubtitle => 'ピュアブラックの背景'; @override String get appearanceChooseAccentColor => 'Choose Accent Color'; @override - String get appearanceChooseTheme => 'Theme Mode'; + String get appearanceChooseTheme => 'テーマモード'; @override - String get queueTitle => 'Download Queue'; + String get queueTitle => 'ダウンロードキュー'; @override - String get queueClearAll => 'Clear All'; + String get queueClearAll => 'すべて消去'; @override String get queueClearAllMessage => diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0e96f465..f431aa0e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -13,67 +13,70 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; @override - String get navHome => 'Home'; + String get navHome => 'Главная'; @override - String get navHistory => 'History'; + String get navHistory => 'История'; @override - String get navSettings => 'Settings'; + String get navSettings => 'Настройки'; @override - String get navStore => 'Store'; + String get navStore => 'Магазин'; @override - String get homeTitle => 'Home'; + String get homeTitle => 'Главная'; @override - String get homeSearchHint => 'Paste Spotify URL or search...'; + String get homeSearchHint => 'Вставьте URL Spotify или выполните поиск...'; @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 или ищите по названию'; @override - String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; + String get homeSupports => + 'Поддерживается: Трек, Альбом, Плейлист, URL исполнителя'; @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'; + String get historyFilterAll => 'Все'; @override - String get historyFilterAlbums => 'Albums'; + String get historyFilterAlbums => 'Альбомы'; @override - String get historyFilterSingles => 'Singles'; + String get historyFilterSingles => 'Синглы'; @override String historyTracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count треков', + one: '1 трек', + many: '$count треков', + few: '$count трека', ); return '$_temp0'; } @@ -83,247 +86,254 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count albums', - one: '1 album', + other: '$count альбомов', + one: '1 альбом', + many: '$count альбомов', + few: '$count альбома', ); return '$_temp0'; } @override - String get historyNoDownloads => 'No download history'; + String get historyNoDownloads => 'Нет истории скачиваний'; @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; + String get historyNoDownloadsSubtitle => 'Скачанные треки появятся здесь'; @override - String get historyNoAlbums => 'No album downloads'; + String get historyNoAlbums => 'Нет скачанных альбомов'; @override String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; + 'Скачайте несколько треков из альбома, чтобы увидеть их здесь'; @override - String get historyNoSingles => 'No single downloads'; + String get historyNoSingles => 'Нет скачанных синглов'; @override String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; + 'Здесь будут отображаться загрузки синглов'; @override - String get settingsTitle => 'Settings'; + String get settingsTitle => 'Настройки'; @override - String get settingsDownload => 'Download'; + String get settingsDownload => 'Скачивание'; @override - String get settingsAppearance => 'Appearance'; + String get settingsAppearance => 'Внешний вид'; @override - String get settingsOptions => 'Options'; + String get settingsOptions => 'Опции'; @override - String get settingsExtensions => 'Extensions'; + String get settingsExtensions => 'Расширения'; @override - String get settingsAbout => 'About'; + String get settingsAbout => 'О программе'; @override - String get downloadTitle => 'Download'; + String get downloadTitle => 'Скачивание'; @override - String get downloadLocation => 'Download Location'; + String get downloadLocation => 'Папка для скачивания'; @override - String get downloadLocationSubtitle => 'Choose where to save files'; + String get downloadLocationSubtitle => 'Выберите, куда сохранить файлы'; @override - String get downloadLocationDefault => 'Default location'; + String get downloadLocationDefault => 'Расположение по умолчанию'; @override - String get downloadDefaultService => 'Default Service'; + String get downloadDefaultService => 'Сервис по умолчанию'; @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; + String get downloadDefaultServiceSubtitle => + 'Сервис, используемый для скачивания'; @override - String get downloadDefaultQuality => 'Default Quality'; + String get downloadDefaultQuality => 'Качество по умолчанию'; @override - String get downloadAskQuality => 'Ask Quality Before Download'; + String get downloadAskQuality => 'Спрашивать качество перед скачиванием'; @override String get downloadAskQualitySubtitle => - 'Show quality picker for each download'; + 'Показывать выбор качества для каждого скачивания'; @override - String get downloadFilenameFormat => 'Filename Format'; + String get downloadFilenameFormat => 'Формат имени файла'; @override - String get downloadFolderOrganization => 'Folder Organization'; + String get downloadFolderOrganization => 'Организация папок'; @override - String get downloadSeparateSingles => 'Separate Singles'; + String get downloadSeparateSingles => 'Разделять синглы'; @override String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; + 'Помещать синглы в отдельную папку'; @override - String get qualityBest => 'Best Available'; + String get qualityBest => 'Лучшее из доступных'; @override String get qualityFlac => 'FLAC'; @override - String get quality320 => '320 kbps'; + String get quality320 => '320 кбит/с'; @override - String get quality128 => '128 kbps'; + String get quality128 => '128 кбит/с'; @override - String get appearanceTitle => 'Appearance'; + String get appearanceTitle => 'Внешний вид'; @override - String get appearanceTheme => 'Theme'; + String get appearanceTheme => 'Тема'; @override - String get appearanceThemeSystem => 'System'; + String get appearanceThemeSystem => 'Системная'; @override - String get appearanceThemeLight => 'Light'; + String get appearanceThemeLight => 'Светлая'; @override - String get appearanceThemeDark => 'Dark'; + String get appearanceThemeDark => 'Тёмная'; @override - String get appearanceDynamicColor => 'Dynamic Color'; + String get appearanceDynamicColor => 'Динамический цвет'; @override - String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + String get appearanceDynamicColorSubtitle => + 'Использовать цвета из ваших обоев'; @override - String get appearanceAccentColor => 'Accent Color'; + String get appearanceAccentColor => 'Акцентный цвет'; @override - String get appearanceHistoryView => 'History View'; + String get appearanceHistoryView => 'Отображение истории'; @override - String get appearanceHistoryViewList => 'List'; + String get appearanceHistoryViewList => 'Список'; @override - String get appearanceHistoryViewGrid => 'Grid'; + String get appearanceHistoryViewGrid => 'Сетка'; @override - String get optionsTitle => 'Options'; + String get optionsTitle => 'Опции'; @override - String get optionsSearchSource => 'Search Source'; + String get optionsSearchSource => 'Поиск источника'; @override - String get optionsPrimaryProvider => 'Primary Provider'; + String get optionsPrimaryProvider => 'Основной провайдер'; @override String get optionsPrimaryProviderSubtitle => - 'Service used when searching by track name.'; + 'Сервис, используемый при поиске по названию трека.'; @override String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; + return 'Используется расширение: $extensionName'; } @override String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; + 'Нажмите Deezer или Spotify для возврата с расширения'; @override - String get optionsAutoFallback => 'Auto Fallback'; + String get optionsAutoFallback => 'Автоматический переход'; @override String get optionsAutoFallbackSubtitle => - 'Try other services if download fails'; + 'Попробовать другие сервисы при сбое загрузки'; @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; + String get optionsUseExtensionProviders => + 'Использовать провайдера расширений'; @override - String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; + String get optionsUseExtensionProvidersOn => + 'Сначала будут опробованы расширения'; @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + String get optionsUseExtensionProvidersOff => + 'Использование только встроенных провайдеров'; @override - String get optionsEmbedLyrics => 'Embed Lyrics'; + String get optionsEmbedLyrics => 'Вставить текст песни'; @override String get optionsEmbedLyricsSubtitle => - 'Embed synced lyrics into FLAC files'; + 'Вставить синхронизированные тексты в FLAC файлы'; @override - String get optionsMaxQualityCover => 'Max Quality Cover'; + String get optionsMaxQualityCover => 'Максимальное качество обложки'; @override String get optionsMaxQualityCoverSubtitle => - 'Download highest resolution cover art'; + 'Скачивать обложку в макс. разрешении'; @override - String get optionsConcurrentDownloads => 'Concurrent Downloads'; + String get optionsConcurrentDownloads => 'Одновременные загрузки'; @override - String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + String get optionsConcurrentSequential => 'Последовательно (1 за раз)'; @override String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; + return '$count параллельных загрузок'; } @override String get optionsConcurrentWarning => - 'Parallel downloads may trigger rate limiting'; + 'Параллельные загрузки могут вызвать ограничение скорости'; @override - String get optionsExtensionStore => 'Extension Store'; + String get optionsExtensionStore => 'Магазин расширений'; @override - String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + String get optionsExtensionStoreSubtitle => + 'Показывать вкладку Магазин в гл. меню'; @override - String get optionsCheckUpdates => 'Check for Updates'; + String get optionsCheckUpdates => 'Проверить обновления'; @override - String get optionsCheckUpdatesSubtitle => - 'Notify when new version is available'; + String get optionsCheckUpdatesSubtitle => 'Уведомлять о наличии новой версии'; @override - String get optionsUpdateChannel => 'Update Channel'; + String get optionsUpdateChannel => 'Канал обновлений'; @override - String get optionsUpdateChannelStable => 'Stable releases only'; + String get optionsUpdateChannelStable => 'Только стабильные релизы'; @override - String get optionsUpdateChannelPreview => 'Get preview releases'; + String get optionsUpdateChannelPreview => 'Предварительные версии'; @override String get optionsUpdateChannelWarning => - 'Preview may contain bugs or incomplete features'; + 'Предварительная версия может содержать ошибки или неполные функции'; @override - String get optionsClearHistory => 'Clear Download History'; + String get optionsClearHistory => 'Очистить историю загрузок'; @override String get optionsClearHistorySubtitle => - 'Remove all downloaded tracks from history'; + 'Удалить все скачанные треки из истории'; @override - String get optionsDetailedLogging => 'Detailed Logging'; + String get optionsDetailedLogging => 'Подробный лог'; @override - String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + String get optionsDetailedLoggingOn => 'Ведутся подробные логи'; @override - String get optionsDetailedLoggingOff => 'Enable for bug reports'; + String get optionsDetailedLoggingOff => 'Включить для отчётов об ошибках'; @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; + String get optionsSpotifyCredentials => 'Учётные данные Spotify'; @override String optionsSpotifyCredentialsConfigured(String clientId) { @@ -331,804 +341,821 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + String get optionsSpotifyCredentialsRequired => + 'Необходимо - нажмите для настройки'; @override String get optionsSpotifyWarning => - 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + 'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com'; @override - String get extensionsTitle => 'Extensions'; + String get extensionsTitle => 'Расширения'; @override - String get extensionsInstalled => 'Installed Extensions'; + String get extensionsInstalled => 'Установленные расширения'; @override - String get extensionsNone => 'No extensions installed'; + String get extensionsNone => 'Нет установленных расширений'; @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; + String get extensionsNoneSubtitle => + 'Установка расширений из вкладки Магазин'; @override - String get extensionsEnabled => 'Enabled'; + String get extensionsEnabled => 'Включено'; @override - String get extensionsDisabled => 'Disabled'; + String get extensionsDisabled => 'Выключено'; @override String extensionsVersion(String version) { - return 'Version $version'; + return 'Версия $version'; } @override String extensionsAuthor(String author) { - return 'by $author'; + return 'от $author'; } @override - String get extensionsUninstall => 'Uninstall'; + String get extensionsUninstall => 'Удалить'; @override - String get extensionsSetAsSearch => 'Set as Search Provider'; + String get extensionsSetAsSearch => 'Установить в качестве поисковой системы'; @override - String get storeTitle => 'Extension Store'; + String get storeTitle => 'Магазин расширений'; @override - String get storeSearch => 'Search extensions...'; + String get storeSearch => 'Поиск расширений...'; @override - String get storeInstall => 'Install'; + String get storeInstall => 'Установить'; @override - String get storeInstalled => 'Installed'; + String get storeInstalled => 'Установлено'; @override - String get storeUpdate => 'Update'; + String get storeUpdate => 'Обновить'; @override - String get aboutTitle => 'About'; + String get aboutTitle => 'О программе'; @override - String get aboutContributors => 'Contributors'; + String get aboutContributors => 'Участники'; @override - String get aboutMobileDeveloper => 'Mobile version developer'; + String get aboutMobileDeveloper => 'Разработчик мобильной версии'; @override - String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + String get aboutOriginalCreator => 'Создатель оригинального SpotiFLAC'; @override String get aboutLogoArtist => - 'The talented artist who created our beautiful app logo!'; + 'Талантливый художник, который создал наш красивый логотип приложения!'; @override - String get aboutSpecialThanks => 'Special Thanks'; + String get aboutSpecialThanks => 'Особая благодарность'; @override - String get aboutLinks => 'Links'; + String get aboutLinks => 'Ссылки'; @override - String get aboutMobileSource => 'Mobile source code'; + String get aboutMobileSource => 'Исходный код мобильной версии'; @override - String get aboutPCSource => 'PC source code'; + String get aboutPCSource => 'Исходный код ПК версии'; @override - String get aboutReportIssue => 'Report an issue'; + String get aboutReportIssue => 'Сообщить о проблеме'; @override - String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + String get aboutReportIssueSubtitle => 'Сообщите о возникших проблемах'; @override - String get aboutFeatureRequest => 'Feature request'; + String get aboutFeatureRequest => 'Предложить новую функцию'; @override - String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + String get aboutFeatureRequestSubtitle => + 'Предложить новые функции для приложения'; @override - String get aboutSupport => 'Support'; + String get aboutSupport => 'Поддержка'; @override - String get aboutBuyMeCoffee => 'Buy me a coffee'; + String get aboutBuyMeCoffee => 'Купить мне кофе'; @override - String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; + String get aboutBuyMeCoffeeSubtitle => 'Поддержать разработку на Ko-fi'; @override - String get aboutApp => 'App'; + String get aboutApp => 'Приложение'; @override - String get aboutVersion => 'Version'; + String get aboutVersion => 'Версия'; @override String get aboutBinimumDesc => - 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + 'Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!'; @override String get aboutSachinsenalDesc => - 'The original HiFi project creator. The foundation of Tidal integration!'; + 'Оригинальный создатель проекта HiFi. Основатель Tidal интеграции!'; @override String get aboutDoubleDouble => 'DoubleDouble'; @override String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; + 'Удивительный API для загрузок Amazon Music. Спасибо за то, что сделали это бесплатно!'; @override String get aboutDabMusic => 'DAB Music'; @override String get aboutDabMusicDesc => - 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + 'Лучший API для стриминга Qobuz. Без него загрузка файлов в высоком разрешении была бы невозможна!'; @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; @override - String get albumTitle => 'Album'; + String get albumTitle => 'Альбом'; @override String albumTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count треков', + one: '1 трек', + many: '$count треков', + few: '$count трека', ); return '$_temp0'; } @override - String get albumDownloadAll => 'Download All'; + String get albumDownloadAll => 'Скачать всё'; @override - String get albumDownloadRemaining => 'Download Remaining'; + String get albumDownloadRemaining => 'Скачать оставшиеся'; @override - String get playlistTitle => 'Playlist'; + String get playlistTitle => 'Плейлист'; @override - String get artistTitle => 'Artist'; + String get artistTitle => 'Исполнитель'; @override - String get artistAlbums => 'Albums'; + String get artistAlbums => 'Альбомы'; @override - String get artistSingles => 'Singles & EPs'; + String get artistSingles => 'Синглы и EP'; @override - String get artistCompilations => 'Compilations'; + String get artistCompilations => 'Сборники'; @override String artistReleases(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count releases', - one: '1 release', + other: '$count релизов', + one: '1 релиз', + many: '$count релизов', + few: '$count релиза', ); return '$_temp0'; } @override - String get artistPopular => 'Popular'; + String get artistPopular => 'Популярное'; @override String artistMonthlyListeners(String count) { - return '$count monthly listeners'; + return '$count слушателей в месяц'; } @override - String get trackMetadataTitle => 'Track Info'; + String get trackMetadataTitle => 'Информация о треке'; @override - String get trackMetadataArtist => 'Artist'; + String get trackMetadataArtist => 'Исполнитель'; @override - String get trackMetadataAlbum => 'Album'; + String get trackMetadataAlbum => 'Альбом'; @override - String get trackMetadataDuration => 'Duration'; + String get trackMetadataDuration => 'Продолжительность'; @override - String get trackMetadataQuality => 'Quality'; + String get trackMetadataQuality => 'Качество'; @override - String get trackMetadataPath => 'File Path'; + String get trackMetadataPath => 'Путь к файлу'; @override - String get trackMetadataDownloadedAt => 'Downloaded'; + String get trackMetadataDownloadedAt => 'Скачано'; @override - String get trackMetadataService => 'Service'; + String get trackMetadataService => 'Сервис'; @override - String get trackMetadataPlay => 'Play'; + String get trackMetadataPlay => 'Воспроизвести'; @override - String get trackMetadataShare => 'Share'; + String get trackMetadataShare => 'Поделиться'; @override - String get trackMetadataDelete => 'Delete'; + String get trackMetadataDelete => 'Удалить'; @override - String get trackMetadataRedownload => 'Re-download'; + String get trackMetadataRedownload => 'Скачать снова'; @override - String get trackMetadataOpenFolder => 'Open Folder'; + String get trackMetadataOpenFolder => 'Открыть папку'; @override - String get setupTitle => 'Welcome to SpotiFLAC'; + String get setupTitle => 'Добро пожаловать в SpotiFLAC'; @override - String get setupSubtitle => 'Let\'s get you started'; + String get setupSubtitle => 'Давайте начнем'; @override - String get setupStoragePermission => 'Storage Permission'; + String get setupStoragePermission => 'Доступ к хранилищу'; @override String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; + 'Необходимо для сохранения загруженных файлов'; @override - String get setupStoragePermissionGranted => 'Permission granted'; + String get setupStoragePermissionGranted => 'Разрешение предоставлено'; @override - String get setupStoragePermissionDenied => 'Permission denied'; + String get setupStoragePermissionDenied => 'Разрешение не предоставлено'; @override - String get setupGrantPermission => 'Grant Permission'; + String get setupGrantPermission => 'Предоставить разрешение'; @override - String get setupDownloadLocation => 'Download Location'; + String get setupDownloadLocation => 'Папка для скачивания'; @override - String get setupChooseFolder => 'Choose Folder'; + String get setupChooseFolder => 'Выбрать папку'; @override - String get setupContinue => 'Continue'; + String get setupContinue => 'Продолжить'; @override - String get setupSkip => 'Skip for now'; + String get setupSkip => 'Пропустить'; @override - String get setupStorageAccessRequired => 'Storage Access Required'; + String get setupStorageAccessRequired => 'Требуется доступ к хранилищу'; @override String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; + 'SpotiFLAC требуется разрешение \"Доступ ко всем файлам\" для сохранения музыкальных файлов в выбранную папку.'; @override String get setupStorageAccessMessageAndroid11 => - 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; + 'Для Android 11+ требуется разрешение \"Доступ ко всем файлам\" для сохранения файлов в выбранную вами папку загрузки.'; @override - String get setupOpenSettings => 'Open Settings'; + String get setupOpenSettings => 'Открыть настройки'; @override String get setupPermissionDeniedMessage => - 'Permission denied. Please grant all permissions to continue.'; + 'В разрешении отказано. Пожалуйста, предоставьте все разрешения для продолжения.'; @override String setupPermissionRequired(String permissionType) { - return '$permissionType Permission Required'; + return 'Требуется разрешение $permissionType'; } @override String setupPermissionRequiredMessage(String permissionType) { - return '$permissionType permission is required for the best experience. You can change this later in Settings.'; + return 'Для оптимальной работы требуется разрешение $permissionType. Вы можете изменить это позже в настройках.'; } @override - String get setupSelectDownloadFolder => 'Select Download Folder'; + String get setupSelectDownloadFolder => 'Выбрать папку для скачивания'; @override - String get setupUseDefaultFolder => 'Use Default Folder?'; + String get setupUseDefaultFolder => 'Использовать папку по умолчанию?'; @override String get setupNoFolderSelected => - 'No folder selected. Would you like to use the default Music folder?'; + 'Папка не выбрана. Хотите использовать папку Музыка по умолчанию?'; @override - String get setupUseDefault => 'Use Default'; + String get setupUseDefault => 'По умолчанию'; @override - String get setupDownloadLocationTitle => 'Download Location'; + String get setupDownloadLocationTitle => 'Папка для скачивания'; @override String get setupDownloadLocationIosMessage => - 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; + 'В iOS загрузки сохраняются в папке Документы приложения. Вы можете получить к ним доступ через приложение Файлы.'; @override - String get setupAppDocumentsFolder => 'App Documents Folder'; + String get setupAppDocumentsFolder => 'Папка Документы приложения'; @override String get setupAppDocumentsFolderSubtitle => - 'Recommended - accessible via Files app'; + 'Рекомендуется - доступ через Файлы'; @override - String get setupChooseFromFiles => 'Choose from Files'; + String get setupChooseFromFiles => 'Выбрать из файлов'; @override - String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; + String get setupChooseFromFilesSubtitle => + 'Выберите iCloud или другое местоположение'; @override String get setupIosEmptyFolderWarning => - 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; + 'Ограничение iOS: пустые папки не могут быть выбраны. Выберите папку, содержащую хотя бы один файл.'; @override - String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC'; @override - String get setupStepStorage => 'Storage'; + String get setupStepStorage => 'Хранилище'; @override - String get setupStepNotification => 'Notification'; + String get setupStepNotification => 'Уведомления'; @override - String get setupStepFolder => 'Folder'; + String get setupStepFolder => 'Папка'; @override String get setupStepSpotify => 'Spotify'; @override - String get setupStepPermission => 'Permission'; + String get setupStepPermission => 'Разрешение'; @override - String get setupStorageGranted => 'Storage Permission Granted!'; + String get setupStorageGranted => 'Доступ к хранилищу предоставлен!'; @override - String get setupStorageRequired => 'Storage Permission Required'; + String get setupStorageRequired => 'Требуется доступ к хранилищу'; @override String get setupStorageDescription => - 'SpotiFLAC needs storage permission to save your downloaded music files.'; + 'SpotiFLAC требуется разрешение на хранение для сохранения скачанных файлов.'; @override - String get setupNotificationGranted => 'Notification Permission Granted!'; + String get setupNotificationGranted => + 'Разрешение на уведомление предоставлено!'; @override - String get setupNotificationEnable => 'Enable Notifications'; + String get setupNotificationEnable => 'Включить уведомления'; @override String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; + 'Получайте уведомления о завершении загрузки или о необходимости привлечения внимания.'; @override - String get setupFolderSelected => 'Download Folder Selected!'; + String get setupFolderSelected => 'Папка для загрузки выбрана!'; @override - String get setupFolderChoose => 'Choose Download Folder'; + String get setupFolderChoose => 'Выбрать папку для скачивания'; @override String get setupFolderDescription => - 'Select a folder where your downloaded music will be saved.'; + 'Выберите папку, в которой будет сохраняться скачанная музыка.'; @override - String get setupChangeFolder => 'Change Folder'; + String get setupChangeFolder => 'Сменить папку'; @override - String get setupSelectFolder => 'Select Folder'; + String get setupSelectFolder => 'Выбрать папку'; @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; + String get setupSpotifyApiOptional => 'Spotify API (необязательно)'; @override String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; + 'Добавьте свои учётные данные Spotify для улучшения результатов поиска и доступа к эксклюзивному контенту Spotify.'; @override - String get setupUseSpotifyApi => 'Use Spotify API'; + String get setupUseSpotifyApi => 'Использовать Spotify API'; @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; + String get setupEnterCredentialsBelow => 'Введите ваши учётные данные ниже'; @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; + String get setupUsingDeezer => 'Использование Deezer (аккаунт не требуется)'; @override - String get setupEnterClientId => 'Enter Spotify Client ID'; + String get setupEnterClientId => 'Введите Client ID Spotify'; @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; + String get setupEnterClientSecret => 'Введите Spotify Client Secret'; @override String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; + 'Получите бесплатный API учётной записи на панели разработчика Spotify.'; @override - String get setupEnableNotifications => 'Enable Notifications'; + String get setupEnableNotifications => 'Включить уведомления'; @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; + String get setupProceedToNextStep => + 'Теперь вы можете перейти к следующему шагу.'; @override String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; + 'Вы будете получать уведомления о ходе загрузки.'; @override String get setupNotificationBackgroundDescription => - 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; + 'Получайте уведомления о ходе и завершении загрузки. Это поможет вам отслеживать загрузки, когда приложение находится в фоновом режиме.'; @override - String get setupSkipForNow => 'Skip for now'; + String get setupSkipForNow => 'Пропустить'; @override - String get setupBack => 'Back'; + String get setupBack => 'Назад'; @override - String get setupNext => 'Next'; + String get setupNext => 'Далее'; @override - String get setupGetStarted => 'Get Started'; + String get setupGetStarted => 'Приступить к работе'; @override - String get setupSkipAndStart => 'Skip & Start'; + String get setupSkipAndStart => 'Пропустить и начать'; @override String get setupAllowAccessToManageFiles => - 'Please enable \"Allow access to manage all files\" in the next screen.'; + 'Пожалуйста, включите \"Разрешить доступ для управления всеми файлами\" на следующем экране.'; @override String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; + 'Получить учётные данные с developer.spotify.com'; @override - String get dialogCancel => 'Cancel'; + String get dialogCancel => 'Отмена'; @override - String get dialogOk => 'OK'; + String get dialogOk => 'ОК'; @override - String get dialogSave => 'Save'; + String get dialogSave => 'Сохранить'; @override - String get dialogDelete => 'Delete'; + String get dialogDelete => 'Удалить'; @override - String get dialogRetry => 'Retry'; + String get dialogRetry => 'Повторить'; @override - String get dialogClose => 'Close'; + String get dialogClose => 'Закрыть'; @override - String get dialogYes => 'Yes'; + String get dialogYes => 'Да'; @override - String get dialogNo => 'No'; + String get dialogNo => 'Нет'; @override - String get dialogClear => 'Clear'; + String get dialogClear => 'Очистить'; @override - String get dialogConfirm => 'Confirm'; + String get dialogConfirm => 'Подтвердить'; @override - String get dialogDone => 'Done'; + String get dialogDone => 'Готово'; @override - String get dialogImport => 'Import'; + String get dialogImport => 'Импорт'; @override - String get dialogDiscard => 'Discard'; + String get dialogDiscard => 'Отменить'; @override - String get dialogRemove => 'Remove'; + String get dialogRemove => 'Убрать'; @override - String get dialogUninstall => 'Uninstall'; + String get dialogUninstall => 'Удалить'; @override - String get dialogDiscardChanges => 'Discard Changes?'; + String get dialogDiscardChanges => 'Отменить изменения?'; @override String get dialogUnsavedChanges => - 'You have unsaved changes. Do you want to discard them?'; + 'Есть несохраненные изменения. Отменить их?'; @override - String get dialogDownloadFailed => 'Download Failed'; + String get dialogDownloadFailed => 'Ошибка скачивания'; @override - String get dialogTrackLabel => 'Track:'; + String get dialogTrackLabel => 'Трек:'; @override - String get dialogArtistLabel => 'Artist:'; + String get dialogArtistLabel => 'Исполнитель:'; @override - String get dialogErrorLabel => 'Error:'; + String get dialogErrorLabel => 'Ошибка:'; @override - String get dialogClearAll => 'Clear All'; + String get dialogClearAll => 'Очистить всё'; @override String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; + 'Вы уверены, что хотите очистить все загрузки?'; @override - String get dialogRemoveFromDevice => 'Remove from device?'; + String get dialogRemoveFromDevice => 'Удалить с устройства?'; @override - String get dialogRemoveExtension => 'Remove Extension'; + String get dialogRemoveExtension => 'Удалить расширение'; @override String get dialogRemoveExtensionMessage => - 'Are you sure you want to remove this extension? This cannot be undone.'; + 'Вы уверены, что хотите удалить это расширение? Это действие не может быть отменено.'; @override - String get dialogUninstallExtension => 'Uninstall Extension?'; + String get dialogUninstallExtension => 'Удалить расширение?'; @override String dialogUninstallExtensionMessage(String extensionName) { - return 'Are you sure you want to remove $extensionName?'; + return 'Вы уверены, что хотите удалить $extensionName?'; } @override - String get dialogClearHistoryTitle => 'Clear History'; + String get dialogClearHistoryTitle => 'Очистить историю'; @override String get dialogClearHistoryMessage => - 'Are you sure you want to clear all download history? This cannot be undone.'; + 'Вы уверены, что хотите удалить всю историю загрузок? Это действие необратимо.'; @override - String get dialogDeleteSelectedTitle => 'Delete Selected'; + String get dialogDeleteSelectedTitle => 'Удалить выбранные'; @override String dialogDeleteSelectedMessage(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + one: 'трек', + many: 'треков', + few: 'трека', ); - return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.'; } @override - String get dialogImportPlaylistTitle => 'Import Playlist'; + String get dialogImportPlaylistTitle => 'Импорт плейлиста'; @override String dialogImportPlaylistMessage(int count) { - return 'Found $count tracks in CSV. Add them to download queue?'; + return 'Найдено $count треков в CSV. Добавить их в очередь загрузки?'; } @override String snackbarAddedToQueue(String trackName) { - return 'Added \"$trackName\" to queue'; + return '\"$trackName\" добавлен в очередь'; } @override String snackbarAddedTracksToQueue(int count) { - return 'Added $count tracks to queue'; + return 'Добавлено $count треков в очередь'; } @override String snackbarAlreadyDownloaded(String trackName) { - return '\"$trackName\" already downloaded'; + return '\"$trackName\" уже скачан'; } @override - String get snackbarHistoryCleared => 'History cleared'; + String get snackbarHistoryCleared => 'История очищена'; @override - String get snackbarCredentialsSaved => 'Credentials saved'; + String get snackbarCredentialsSaved => 'Учётные данные сохранены'; @override - String get snackbarCredentialsCleared => 'Credentials cleared'; + String get snackbarCredentialsCleared => 'Учётные данные очищены'; @override String snackbarDeletedTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + one: 'трек', + many: 'треков', + few: 'трека', ); - return 'Deleted $count $_temp0'; + return 'Удалено $count $_temp0'; } @override String snackbarCannotOpenFile(String error) { - return 'Cannot open file: $error'; + return 'Невозможно открыть файл: $error'; } @override - String get snackbarFillAllFields => 'Please fill all fields'; + String get snackbarFillAllFields => 'Пожалуйста, заполните все поля'; @override - String get snackbarViewQueue => 'View Queue'; + String get snackbarViewQueue => 'Просмотр очереди'; @override String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; + return 'Ошибка загрузки: $error'; } @override String snackbarUrlCopied(String platform) { - return '$platform URL copied to clipboard'; + return '$platform ссылка скопирована в буфер обмена'; } @override - String get snackbarFileNotFound => 'File not found'; + String get snackbarFileNotFound => 'Файл не найден'; @override - String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + String get snackbarSelectExtFile => + 'Пожалуйста, выберите .spotiflac-ext-файл'; @override - String get snackbarProviderPrioritySaved => 'Provider priority saved'; + String get snackbarProviderPrioritySaved => 'Приоритет провайдера сохранён'; @override String get snackbarMetadataProviderSaved => - 'Metadata provider priority saved'; + 'Приоритет провайдера метаданных сохранён'; @override String snackbarExtensionInstalled(String extensionName) { - return '$extensionName installed.'; + return '$extensionName установлено.'; } @override String snackbarExtensionUpdated(String extensionName) { - return '$extensionName updated.'; + return '$extensionName Обновлено.'; } @override - String get snackbarFailedToInstall => 'Failed to install extension'; + String get snackbarFailedToInstall => 'Не удалось установить расширение'; @override - String get snackbarFailedToUpdate => 'Failed to update extension'; + String get snackbarFailedToUpdate => 'Не удалось обновить расширение'; @override - String get errorRateLimited => 'Rate Limited'; + String get errorRateLimited => 'Слишком много запросов'; @override String get errorRateLimitedMessage => - 'Too many requests. Please wait a moment before searching again.'; + 'Слишком много запросов. Пожалуйста, подождите минуту перед повторным поиском.'; @override String errorFailedToLoad(String item) { - return 'Failed to load $item'; + return 'Ошибка загрузки $item'; } @override - String get errorNoTracksFound => 'No tracks found'; + String get errorNoTracksFound => 'Треки не найдены'; @override String errorMissingExtensionSource(String item) { - return 'Cannot load $item: missing extension source'; + return 'Невозможно загрузить $item: отсутствует источник расширения'; } @override - String get statusQueued => 'Queued'; + String get statusQueued => 'В очереди'; @override - String get statusDownloading => 'Downloading'; + String get statusDownloading => 'Скачивание'; @override - String get statusFinalizing => 'Finalizing'; + String get statusFinalizing => 'Завершение'; @override - String get statusCompleted => 'Completed'; + String get statusCompleted => 'Завершено'; @override - String get statusFailed => 'Failed'; + String get statusFailed => 'Неудачно'; @override - String get statusSkipped => 'Skipped'; + String get statusSkipped => 'Пропущено'; @override - String get statusPaused => 'Paused'; + String get statusPaused => 'Приостановлено'; @override - String get actionPause => 'Pause'; + String get actionPause => 'Пауза'; @override - String get actionResume => 'Resume'; + String get actionResume => 'Возобновить'; @override - String get actionCancel => 'Cancel'; + String get actionCancel => 'Отмена'; @override - String get actionStop => 'Stop'; + String get actionStop => 'Стоп'; @override - String get actionSelect => 'Select'; + String get actionSelect => 'Выбрать'; @override - String get actionSelectAll => 'Select All'; + String get actionSelectAll => 'Выбрать все'; @override - String get actionDeselect => 'Deselect'; + String get actionDeselect => 'Снять выделение'; @override - String get actionPaste => 'Paste'; + String get actionPaste => 'Вставить'; @override - String get actionImportCsv => 'Import CSV'; + String get actionImportCsv => 'Импорт CSV'; @override - String get actionRemoveCredentials => 'Remove Credentials'; + String get actionRemoveCredentials => 'Удалить учётные данные'; @override - String get actionSaveCredentials => 'Save Credentials'; + String get actionSaveCredentials => 'Сохранить учётные данные'; @override String selectionSelected(int count) { - return '$count selected'; + return '$count выбрано'; } @override - String get selectionAllSelected => 'All tracks selected'; + String get selectionAllSelected => 'Все треки выбраны'; @override - String get selectionTapToSelect => 'Tap tracks to select'; + String get selectionTapToSelect => 'Нажмите на треки для выбора'; @override String selectionDeleteTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + one: 'трек', + many: 'треков', + few: 'трека', ); - return 'Delete $count $_temp0'; + return 'Удалить $count $_temp0'; } @override - String get selectionSelectToDelete => 'Select tracks to delete'; + String get selectionSelectToDelete => 'Выберите треки для удаления'; @override String progressFetchingMetadata(int current, int total) { - return 'Fetching metadata... $current/$total'; + return 'Получение метаданных... $current/$total'; } @override - String get progressReadingCsv => 'Reading CSV...'; + String get progressReadingCsv => 'Чтение CSV...'; @override - String get searchSongs => 'Songs'; + String get searchSongs => 'Песни'; @override - String get searchArtists => 'Artists'; + String get searchArtists => 'Исполнители'; @override - String get searchAlbums => 'Albums'; + String get searchAlbums => 'Альбомы'; @override - String get searchPlaylists => 'Playlists'; + String get searchPlaylists => 'Плейлисты'; @override - String get tooltipPlay => 'Play'; + String get tooltipPlay => 'Воспроизвести'; @override - String get tooltipCancel => 'Cancel'; + String get tooltipCancel => 'Отмена'; @override - String get tooltipStop => 'Stop'; + String get tooltipStop => 'Стоп'; @override - String get tooltipRetry => 'Retry'; + String get tooltipRetry => 'Повторить'; @override - String get tooltipRemove => 'Remove'; + String get tooltipRemove => 'Убрать'; @override - String get tooltipClear => 'Clear'; + String get tooltipClear => 'Очистить'; @override - String get tooltipPaste => 'Paste'; + String get tooltipPaste => 'Вставить'; @override - String get filenameFormat => 'Filename Format'; + String get filenameFormat => 'Формат имени файла'; @override String filenameFormatPreview(String preview) { - return 'Preview: $preview'; + return 'Предпросмотр: $preview'; } @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; + String get filenameAvailablePlaceholders => 'Доступные заполнители:'; @override String filenameHint(Object artist, Object title) { @@ -1136,342 +1163,345 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get folderOrganization => 'Folder Organization'; + String get folderOrganization => 'Организация папок'; @override - String get folderOrganizationNone => 'No organization'; + String get folderOrganizationNone => 'Без организации'; @override - String get folderOrganizationByArtist => 'By Artist'; + String get folderOrganizationByArtist => 'По исполнителю'; @override - String get folderOrganizationByAlbum => 'By Album'; + String get folderOrganizationByAlbum => 'По альбому'; @override - String get folderOrganizationByArtistAlbum => 'Artist/Album'; + String get folderOrganizationByArtistAlbum => 'Исполнитель/Альбом'; @override String get folderOrganizationDescription => - 'Organize downloaded files into folders'; + 'Сортировать скачанные файлы по папкам'; @override - String get folderOrganizationNoneSubtitle => 'All files in download folder'; + String get folderOrganizationNoneSubtitle => 'Все файлы в папке загрузок'; @override String get folderOrganizationByArtistSubtitle => - 'Separate folder for each artist'; + 'Отдельная папка для каждого исполнителя'; @override String get folderOrganizationByAlbumSubtitle => - 'Separate folder for each album'; + 'Отдельная папка для каждого альбома'; @override String get folderOrganizationByArtistAlbumSubtitle => - 'Nested folders for artist and album'; + 'Вложенные папки для исполнителей и альбомов'; @override - String get updateAvailable => 'Update Available'; + String get updateAvailable => 'Доступно обновление'; @override String updateNewVersion(String version) { - return 'Version $version is available'; + return 'Версия $version доступна'; } @override - String get updateDownload => 'Download'; + String get updateDownload => 'Скачать'; @override - String get updateLater => 'Later'; + String get updateLater => 'Позже'; @override - String get updateChangelog => 'Changelog'; + String get updateChangelog => 'Список изменений'; @override - String get updateStartingDownload => 'Starting download...'; + String get updateStartingDownload => 'Загрузка началась...'; @override - String get updateDownloadFailed => 'Download failed'; + String get updateDownloadFailed => 'Не удалось скачать'; @override - String get updateFailedMessage => 'Failed to download update'; + String get updateFailedMessage => 'Сбой загрузки обновления'; @override - String get updateNewVersionReady => 'A new version is ready'; + String get updateNewVersionReady => 'Доступна новая версия'; @override - String get updateCurrent => 'Current'; + String get updateCurrent => 'Текущая'; @override - String get updateNew => 'New'; + String get updateNew => 'Новая'; @override - String get updateDownloading => 'Downloading...'; + String get updateDownloading => 'Скачивание...'; @override - String get updateWhatsNew => 'What\'s New'; + String get updateWhatsNew => 'Что нового'; @override - String get updateDownloadInstall => 'Download & Install'; + String get updateDownloadInstall => 'Скачать и установить'; @override - String get updateDontRemind => 'Don\'t remind'; + String get updateDontRemind => 'Не напоминать'; @override - String get providerPriority => 'Provider Priority'; + String get providerPriority => 'Приоритет провайдера'; @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; + String get providerPrioritySubtitle => 'Перетащите для изменения порядка'; @override - String get providerPriorityTitle => 'Provider Priority'; + String get providerPriorityTitle => 'Приоритет провайдера'; @override String get providerPriorityDescription => - 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; + 'Перетаскивайте, чтобы изменить порядок провайдеров загрузки. Приложение будет пробовать провайдеров сверху вниз при загрузке треков.'; @override String get providerPriorityInfo => - 'If a track is not available on the first provider, the app will automatically try the next one.'; + 'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.'; @override - String get providerBuiltIn => 'Built-in'; + String get providerBuiltIn => 'Встроенные'; @override - String get providerExtension => 'Extension'; + String get providerExtension => 'Расширение'; @override - String get metadataProviderPriority => 'Metadata Provider Priority'; + String get metadataProviderPriority => 'Приоритет провайдера метаданных'; @override String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; + 'Порядок, используемый при получении метаданных'; @override - String get metadataProviderPriorityTitle => 'Metadata Priority'; + String get metadataProviderPriorityTitle => 'Приоритет метаданных'; @override String get metadataProviderPriorityDescription => - 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; + 'Перетаскивайте, чтобы изменить порядок провайдеров метаданных. Приложение будет пробовать провайдеров сверху вниз при поиске треков и извлечении метаданных.'; @override String get metadataProviderPriorityInfo => - 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; + 'Deezer не имеет ограничений по скорости и рекомендуется в качестве основного. Spotify может ограничивать скорость после большого количества запросов.'; @override - String get metadataNoRateLimits => 'No rate limits'; + String get metadataNoRateLimits => 'Без ограничений по скорости'; @override - String get metadataMayRateLimit => 'May rate limit'; + String get metadataMayRateLimit => 'Есть ограничения по скорости'; @override - String get logTitle => 'Logs'; + String get logTitle => 'Логи'; @override - String get logCopy => 'Copy Logs'; + String get logCopy => 'Скопировать логи'; @override - String get logClear => 'Clear Logs'; + String get logClear => 'Очистить логи'; @override - String get logShare => 'Share Logs'; + String get logShare => 'Поделиться логами'; @override - String get logEmpty => 'No logs yet'; + String get logEmpty => 'Логов нет'; @override - String get logCopied => 'Logs copied to clipboard'; + String get logCopied => 'Логи скопированы в буфер обмена'; @override - String get logSearchHint => 'Search logs...'; + String get logSearchHint => 'Поиск логов...'; @override - String get logFilterLevel => 'Level'; + String get logFilterLevel => 'Уровень'; @override - String get logFilterSection => 'Filter'; + String get logFilterSection => 'Фильтр'; @override - String get logShareLogs => 'Share logs'; + String get logShareLogs => 'Поделиться логами'; @override - String get logClearLogs => 'Clear logs'; + String get logClearLogs => 'Очистить логи'; @override - String get logClearLogsTitle => 'Clear Logs'; + String get logClearLogsTitle => 'Очистить логи'; @override - String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; + String get logClearLogsMessage => 'Вы уверены, что хотите очистить все логи?'; @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; + String get logIspBlocking => 'ОБНАРУЖЕНА БЛОКИРОВКА ИНТЕРНЕТ ПРОВАЙДЕРОМ'; @override - String get logRateLimited => 'RATE LIMITED'; + String get logRateLimited => 'ОГРАНИЧЕННАЯ СКОРОСТЬ'; @override - String get logNetworkError => 'NETWORK ERROR'; + String get logNetworkError => 'ОШИБКА СЕТИ'; @override - String get logTrackNotFound => 'TRACK NOT FOUND'; + String get logTrackNotFound => 'ТРЕК НЕ НАЙДЕН'; @override - String get logFilterBySeverity => 'Filter logs by severity'; + String get logFilterBySeverity => 'Фильтровать логи по серьезности'; @override - String get logNoLogsYet => 'No logs yet'; + String get logNoLogsYet => 'Логов нет'; @override - String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; + String get logNoLogsYetSubtitle => + 'Логи появятся здесь по мере использования приложения'; @override - String get logIssueSummary => 'Issue Summary'; + String get logIssueSummary => 'Краткое описание проблемы'; @override String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; + 'Ваш провайдер может блокировать доступ к сервисам скачивания'; @override String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + 'Попробуйте использовать VPN или измените DNS на 1.1.1.1 или 8.8.8.8'; @override - String get logRateLimitedDescription => 'Too many requests to the service'; + String get logRateLimitedDescription => 'Слишком много запросов к сервису'; @override String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; + 'Подождите несколько минут, прежде чем повторить попытку'; @override - String get logNetworkErrorDescription => 'Connection issues detected'; + String get logNetworkErrorDescription => 'Обнаружены проблемы с подключением'; @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; + String get logNetworkErrorSuggestion => 'Проверьте подключение к Интернету'; @override String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; + 'Некоторые треки не найдены в сервисах загрузки'; @override String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; + 'Трек может быть недоступен в lossless формате'; @override String logTotalErrors(int count) { - return 'Total errors: $count'; + return 'Всего ошибок: $count'; } @override String logAffected(String domains) { - return 'Affected: $domains'; + return 'Затронуто: $domains'; } @override String logEntriesFiltered(int count) { - return 'Entries ($count filtered)'; + return 'Записи ($count фильтровано)'; } @override String logEntries(int count) { - return 'Entries ($count)'; + return 'Записи ($count)'; } @override - String get credentialsTitle => 'Spotify Credentials'; + String get credentialsTitle => 'Учётные данные Spotify'; @override String get credentialsDescription => - 'Enter your Client ID and Secret to use your own Spotify application quota.'; + 'Введите свой Client ID и Secret, чтобы использовать собственные квоты в Spotify.'; @override String get credentialsClientId => 'Client ID'; @override - String get credentialsClientIdHint => 'Paste Client ID'; + String get credentialsClientIdHint => 'Вставьте Client ID'; @override String get credentialsClientSecret => 'Client Secret'; @override - String get credentialsClientSecretHint => 'Paste Client Secret'; + String get credentialsClientSecretHint => 'Вставьте Client Secret'; @override - String get channelStable => 'Stable'; + String get channelStable => 'Стабильный'; @override - String get channelPreview => 'Preview'; + String get channelPreview => 'Предварительный'; @override - String get sectionSearchSource => 'Search Source'; + String get sectionSearchSource => 'Поиск источника'; @override - String get sectionDownload => 'Download'; + String get sectionDownload => 'Скачивание'; @override - String get sectionPerformance => 'Performance'; + String get sectionPerformance => 'Производительность'; @override - String get sectionApp => 'App'; + String get sectionApp => 'Приложение'; @override - String get sectionData => 'Data'; + String get sectionData => 'Данные'; @override - String get sectionDebug => 'Debug'; + String get sectionDebug => 'Отладка'; @override - String get sectionService => 'Service'; + String get sectionService => 'Сервис'; @override - String get sectionAudioQuality => 'Audio Quality'; + String get sectionAudioQuality => 'Качество аудио'; @override - String get sectionFileSettings => 'File Settings'; + String get sectionFileSettings => 'Настройки файла'; @override - String get sectionColor => 'Color'; + String get sectionColor => 'Цвет'; @override - String get sectionTheme => 'Theme'; + String get sectionTheme => 'Тема'; @override - String get sectionLayout => 'Layout'; + String get sectionLayout => 'Разметка'; @override - String get sectionLanguage => 'Language'; + String get sectionLanguage => 'Язык'; @override - String get appearanceLanguage => 'App Language'; + String get appearanceLanguage => 'Язык приложения'; @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; + String get appearanceLanguageSubtitle => 'Выберите предпочитаемый язык'; @override - String get settingsAppearanceSubtitle => 'Theme, colors, display'; + String get settingsAppearanceSubtitle => 'Тема, цвета, дисплей'; @override - String get settingsDownloadSubtitle => 'Service, quality, filename format'; + String get settingsDownloadSubtitle => + 'Сервисы, качество, формат имени файла'; @override - String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; + String get settingsOptionsSubtitle => + 'Резерв. сервер, тексты песен, обложки, обновления'; @override - String get settingsExtensionsSubtitle => 'Manage download providers'; + String get settingsExtensionsSubtitle => 'Управление провайдерами скачивания'; @override - String get settingsLogsSubtitle => 'View app logs for debugging'; + String get settingsLogsSubtitle => 'Просмотреть логи для отладки'; @override - String get loadingSharedLink => 'Loading shared link...'; + String get loadingSharedLink => 'Загрузка общедоступной ссылки...'; @override - String get pressBackAgainToExit => 'Press back again to exit'; + String get pressBackAgainToExit => 'Нажмите «Назад» ещё раз, чтобы выйти'; @override - String get tracksHeader => 'Tracks'; + String get tracksHeader => 'Треки'; @override String downloadAllCount(int count) { - return 'Download All ($count)'; + return 'Скачать все ($count)'; } @override @@ -1479,366 +1509,375 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count треков', + one: '1 трек', + many: '$count треков', + few: '$count трека', ); return '$_temp0'; } @override - String get trackCopyFilePath => 'Copy file path'; + String get trackCopyFilePath => 'Скопировать путь к файлу'; @override - String get trackRemoveFromDevice => 'Remove from device'; + String get trackRemoveFromDevice => 'Удалить с устройства'; @override - String get trackLoadLyrics => 'Load Lyrics'; + String get trackLoadLyrics => 'Загрузить текст песни'; @override - String get trackMetadata => 'Metadata'; + String get trackMetadata => 'Метаданные'; @override - String get trackFileInfo => 'File Info'; + String get trackFileInfo => 'Информация о файле'; @override - String get trackLyrics => 'Lyrics'; + String get trackLyrics => 'Тексты песен'; @override - String get trackFileNotFound => 'File not found'; + String get trackFileNotFound => 'Файл не найден'; @override - String get trackOpenInDeezer => 'Open in Deezer'; + String get trackOpenInDeezer => 'Открыть в Deezer'; @override - String get trackOpenInSpotify => 'Open in Spotify'; + String get trackOpenInSpotify => 'Открыть в Spotify'; @override - String get trackTrackName => 'Track name'; + String get trackTrackName => 'Название трека'; @override - String get trackArtist => 'Artist'; + String get trackArtist => 'Исполнитель'; @override - String get trackAlbumArtist => 'Album artist'; + String get trackAlbumArtist => 'Исполнитель альбома'; @override - String get trackAlbum => 'Album'; + String get trackAlbum => 'Альбом'; @override - String get trackTrackNumber => 'Track number'; + String get trackTrackNumber => 'Номер трека'; @override - String get trackDiscNumber => 'Disc number'; + String get trackDiscNumber => 'Номер диска'; @override - String get trackDuration => 'Duration'; + String get trackDuration => 'Продолжительность'; @override - String get trackAudioQuality => 'Audio quality'; + String get trackAudioQuality => 'Качество записи'; @override - String get trackReleaseDate => 'Release date'; + String get trackReleaseDate => 'Дата выхода'; @override - String get trackDownloaded => 'Downloaded'; + String get trackDownloaded => 'Скачано'; @override - String get trackCopyLyrics => 'Copy lyrics'; + String get trackCopyLyrics => 'Копировать текст'; @override - String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + String get trackLyricsNotAvailable => + 'Текст песни недоступен для этого трека'; @override - String get trackLyricsTimeout => 'Request timed out. Try again later.'; + String get trackLyricsTimeout => + 'Время ожидания запроса истекло. Повторите попытку позже.'; @override - String get trackLyricsLoadFailed => 'Failed to load lyrics'; + String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни'; @override - String get trackCopiedToClipboard => 'Copied to clipboard'; + String get trackCopiedToClipboard => 'Скопировано в буфер обмена'; @override - String get trackDeleteConfirmTitle => 'Remove from device?'; + String get trackDeleteConfirmTitle => 'Удалить с устройства?'; @override String get trackDeleteConfirmMessage => - 'This will permanently delete the downloaded file and remove it from your history.'; + 'Это приведет к окончательному удалению загруженного файла и его удалению из истории.'; @override String trackCannotOpen(String message) { - return 'Cannot open: $message'; + return 'Невозможно открыть: $message'; } @override - String get dateToday => 'Today'; + String get dateToday => 'Сегодня'; @override - String get dateYesterday => 'Yesterday'; + String get dateYesterday => 'Вчера'; @override String dateDaysAgo(int count) { - return '$count days ago'; + return '$count дней назад'; } @override String dateWeeksAgo(int count) { - return '$count weeks ago'; + return '$count недель назад'; } @override String dateMonthsAgo(int count) { - return '$count months ago'; + return '$count месяцев назад'; } @override - String get concurrentSequential => 'Sequential'; + String get concurrentSequential => 'Последовательно'; @override - String get concurrentParallel2 => '2 Parallel'; + String get concurrentParallel2 => '2 параллельно'; @override - String get concurrentParallel3 => '3 Parallel'; + String get concurrentParallel3 => '3 параллельно'; @override - String get tapToSeeError => 'Tap to see error details'; + String get tapToSeeError => 'Нажмите, чтобы увидеть подробности ошибки'; @override - String get storeFilterAll => 'All'; + String get storeFilterAll => 'Все'; @override - String get storeFilterMetadata => 'Metadata'; + String get storeFilterMetadata => 'Метаданные'; @override - String get storeFilterDownload => 'Download'; + String get storeFilterDownload => 'Скачивание'; @override - String get storeFilterUtility => 'Utility'; + String get storeFilterUtility => 'Утилиты'; @override - String get storeFilterLyrics => 'Lyrics'; + String get storeFilterLyrics => 'Тексты песен'; @override - String get storeFilterIntegration => 'Integration'; + String get storeFilterIntegration => 'Интеграция'; @override - String get storeClearFilters => 'Clear filters'; + String get storeClearFilters => 'Очистить фильтры'; @override - String get storeNoResults => 'No extensions found'; + String get storeNoResults => 'Расширения не найдены'; @override - String get extensionProviderPriority => 'Provider Priority'; + String get extensionProviderPriority => 'Приоритет провайдера'; @override - String get extensionInstallButton => 'Install Extension'; + String get extensionInstallButton => 'Установить расширение'; @override - String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)'; @override - String get extensionDefaultProviderSubtitle => 'Use built-in search'; + String get extensionDefaultProviderSubtitle => + 'Использовать встроенный поиск'; @override - String get extensionAuthor => 'Author'; + String get extensionAuthor => 'Автор'; @override String get extensionId => 'ID'; @override - String get extensionError => 'Error'; + String get extensionError => 'Ошибка'; @override - String get extensionCapabilities => 'Capabilities'; + String get extensionCapabilities => 'Возможности'; @override - String get extensionMetadataProvider => 'Metadata Provider'; + String get extensionMetadataProvider => 'Провайдер метаданных'; @override - String get extensionDownloadProvider => 'Download Provider'; + String get extensionDownloadProvider => 'Провайдер скачивания'; @override - String get extensionLyricsProvider => 'Lyrics Provider'; + String get extensionLyricsProvider => 'Провайдер текстов'; @override - String get extensionUrlHandler => 'URL Handler'; + String get extensionUrlHandler => 'URL-обработчик'; @override - String get extensionQualityOptions => 'Quality Options'; + String get extensionQualityOptions => 'Параметры качества'; @override - String get extensionPostProcessingHooks => 'Post-Processing Hooks'; + String get extensionPostProcessingHooks => 'Хуки постобработки'; @override - String get extensionPermissions => 'Permissions'; + String get extensionPermissions => 'Разрешения'; @override - String get extensionSettings => 'Settings'; + String get extensionSettings => 'Настройки'; @override - String get extensionRemoveButton => 'Remove Extension'; + String get extensionRemoveButton => 'Удалить расширение'; @override - String get extensionUpdated => 'Updated'; + String get extensionUpdated => 'Обновлено'; @override - String get extensionMinAppVersion => 'Min App Version'; + String get extensionMinAppVersion => 'Мин. версия приложения'; @override - String get extensionCustomTrackMatching => 'Custom Track Matching'; + String get extensionCustomTrackMatching => + 'Соответствие пользовательских треков'; @override - String get extensionPostProcessing => 'Post-Processing'; + String get extensionPostProcessing => 'Постобработка'; @override String extensionHooksAvailable(int count) { - return '$count hook(s) available'; + return 'Доступно $count хуков(ов)'; } @override String extensionPatternsCount(int count) { - return '$count pattern(s)'; + return '$count шаблон(ов)'; } @override String extensionStrategy(String strategy) { - return 'Strategy: $strategy'; + return 'Стратегия: $strategy'; } @override - String get extensionsProviderPrioritySection => 'Provider Priority'; + String get extensionsProviderPrioritySection => 'Приоритет провайдера'; @override - String get extensionsInstalledSection => 'Installed Extensions'; + String get extensionsInstalledSection => 'Установленные расширения'; @override - String get extensionsNoExtensions => 'No extensions installed'; + String get extensionsNoExtensions => 'Нет установленных расширений'; @override String get extensionsNoExtensionsSubtitle => - 'Install .spotiflac-ext files to add new providers'; + 'Установите .spotiflac-ext файлы для добавления новых провайдеров'; @override - String get extensionsInstallButton => 'Install Extension'; + String get extensionsInstallButton => 'Установить расширение'; @override String get extensionsInfoTip => - 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; + 'Расширения могут добавлять новые метаданные и провайдеров загрузки. Устанавливайте только расширения из надежных источников.'; @override - String get extensionsInstalledSuccess => 'Extension installed successfully'; + String get extensionsInstalledSuccess => 'Расширение успешно установлено'; @override - String get extensionsDownloadPriority => 'Download Priority'; + String get extensionsDownloadPriority => 'Приоритет скачивания'; @override - String get extensionsDownloadPrioritySubtitle => 'Set download service order'; + String get extensionsDownloadPrioritySubtitle => + 'Установка порядок сервисов скачивания'; @override String get extensionsNoDownloadProvider => - 'No extensions with download provider'; + 'Нет расширений с провайдером загрузки'; @override - String get extensionsMetadataPriority => 'Metadata Priority'; + String get extensionsMetadataPriority => 'Приоритет метаданных'; @override String get extensionsMetadataPrioritySubtitle => - 'Set search & metadata source order'; + 'Установка порядка поиска и источника метаданных'; @override String get extensionsNoMetadataProvider => - 'No extensions with metadata provider'; + 'Нет расширений с провайдером метаданных'; @override - String get extensionsSearchProvider => 'Search Provider'; + String get extensionsSearchProvider => 'Провайдер поиска'; @override - String get extensionsNoCustomSearch => 'No extensions with custom search'; + String get extensionsNoCustomSearch => + 'Нет расширений с пользовательским поиском'; @override String get extensionsSearchProviderDescription => - 'Choose which service to use for searching tracks'; + 'Выберите, какой сервис использовать для поиска треков'; @override - String get extensionsCustomSearch => 'Custom search'; + String get extensionsCustomSearch => 'Пользовательский поиск'; @override - String get extensionsErrorLoading => 'Error loading extension'; + String get extensionsErrorLoading => 'Ошибка загрузки расширения'; @override String get qualityFlacLossless => 'FLAC Lossless'; @override - String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + String get qualityFlacLosslessSubtitle => '16-бит / 44.1 кГц'; @override String get qualityHiResFlac => 'Hi-Res FLAC'; @override - String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + String get qualityHiResFlacSubtitle => '24-бит / до 96кГц'; @override - String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + String get qualityHiResFlacMax => 'Hi-Res FLAC Макс.'; @override - String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц'; @override String get qualityNote => - 'Actual quality depends on track availability from the service'; + 'Фактическое качество зависит от доступности треков в сервисе'; @override - String get downloadAskBeforeDownload => 'Ask Before Download'; + String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; @override - String get downloadDirectory => 'Download Directory'; + String get downloadDirectory => 'Папка для скачивания'; @override - String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + String get downloadSeparateSinglesFolder => 'Отдельная папка для синглов'; @override - String get downloadAlbumFolderStructure => 'Album Folder Structure'; + String get downloadAlbumFolderStructure => 'Структура папок альбома'; @override - String get downloadSaveFormat => 'Save Format'; + String get downloadSaveFormat => 'Формат сохранения'; @override - String get downloadSelectService => 'Select Service'; + String get downloadSelectService => 'Выбор сервиса'; @override - String get downloadSelectQuality => 'Select Quality'; + String get downloadSelectQuality => 'Выбор качества'; @override - String get downloadFrom => 'Download From'; + String get downloadFrom => 'Скачивать из'; @override - String get downloadDefaultQualityLabel => 'Default Quality'; + String get downloadDefaultQualityLabel => 'Качество по умолчанию'; @override - String get downloadBestAvailable => 'Best available'; + String get downloadBestAvailable => 'Лучшее из доступных'; @override - String get folderNone => 'None'; + String get folderNone => 'Отсутствует'; @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; + String get folderNoneSubtitle => + 'Сохранить все файлы непосредственно в папку загрузки'; @override - String get folderArtist => 'Artist'; + String get folderArtist => 'Исполнитель'; @override - String get folderArtistSubtitle => 'Artist Name/filename'; + String get folderArtistSubtitle => 'Исполнитель/имя файла'; @override - String get folderAlbum => 'Album'; + String get folderAlbum => 'Альбом'; @override - String get folderAlbumSubtitle => 'Album Name/filename'; + String get folderAlbumSubtitle => 'Альбом/имя файла'; @override - String get folderArtistAlbum => 'Artist/Album'; + String get folderArtistAlbum => 'Исполнитель/Альбом'; @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; + String get folderArtistAlbumSubtitle => 'Исполнитель/ Альбом/имя файла'; @override String get serviceTidal => 'Tidal'; @@ -1856,145 +1895,151 @@ class AppLocalizationsRu extends AppLocalizations { String get serviceSpotify => 'Spotify'; @override - String get appearanceAmoledDark => 'AMOLED Dark'; + String get appearanceAmoledDark => 'AMOLED'; @override - String get appearanceAmoledDarkSubtitle => 'Pure black background'; + String get appearanceAmoledDarkSubtitle => 'Глубокий чёрный фон'; @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; + String get appearanceChooseAccentColor => 'Выберите акцентный цвет'; @override - String get appearanceChooseTheme => 'Theme Mode'; + String get appearanceChooseTheme => 'Режим темы'; @override - String get queueTitle => 'Download Queue'; + String get queueTitle => 'Очередь скачиваний'; @override - String get queueClearAll => 'Clear All'; + String get queueClearAll => 'Очистить всё'; @override String get queueClearAllMessage => - 'Are you sure you want to clear all downloads?'; + 'Вы уверены, что хотите очистить все загрузки?'; @override - String get queueEmpty => 'No downloads in queue'; + String get queueEmpty => 'Нет загрузок в очереди'; @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; + String get queueEmptySubtitle => 'Добавить треки с главного экрана'; @override - String get queueClearCompleted => 'Clear completed'; + String get queueClearCompleted => 'Очистка завершена'; @override - String get queueDownloadFailed => 'Download Failed'; + String get queueDownloadFailed => 'Ошибка скачивания'; @override - String get queueTrackLabel => 'Track:'; + String get queueTrackLabel => 'Трек:'; @override - String get queueArtistLabel => 'Artist:'; + String get queueArtistLabel => 'Исполнитель:'; @override - String get queueErrorLabel => 'Error:'; + String get queueErrorLabel => 'Ошибка:'; @override - String get queueUnknownError => 'Unknown error'; + String get queueUnknownError => 'Неизвестная ошибка'; @override - String get albumFolderArtistAlbum => 'Artist / Album'; + String get albumFolderArtistAlbum => 'Исполнитель / Альбом'; @override - String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; + String get albumFolderArtistAlbumSubtitle => + 'Альбомы/Исполнитель/Название Альбома/'; @override - String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; + String get albumFolderArtistYearAlbum => 'Исполнитель / [Год] Альбом'; @override String get albumFolderArtistYearAlbumSubtitle => - 'Albums/Artist Name/[2005] Album Name/'; + 'Альбомы/Исполнитель/[2005] Название Альбома/'; @override - String get albumFolderAlbumOnly => 'Album Only'; + String get albumFolderAlbumOnly => 'Только альбом'; @override - String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + String get albumFolderAlbumOnlySubtitle => 'Альбомы/Название Альбома/'; @override - String get albumFolderYearAlbum => '[Year] Album'; + String get albumFolderYearAlbum => '[Год] Альбом'; @override - String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + String get albumFolderYearAlbumSubtitle => + 'Альбомы/[2005] Название Альбома /'; @override - String get downloadedAlbumDeleteSelected => 'Delete Selected'; + String get downloadedAlbumDeleteSelected => 'Удалить выбранные'; @override String downloadedAlbumDeleteMessage(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + one: 'трек', + many: 'треков', + few: 'трека', ); - return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.'; } @override - String get downloadedAlbumTracksHeader => 'Tracks'; + String get downloadedAlbumTracksHeader => 'Треки'; @override String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; + return '$count скачано'; } @override String downloadedAlbumSelectedCount(int count) { - return '$count selected'; + return '$count выбрано'; } @override - String get downloadedAlbumAllSelected => 'All tracks selected'; + String get downloadedAlbumAllSelected => 'Все треки выбраны'; @override - String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + String get downloadedAlbumTapToSelect => 'Нажмите на треки для выбора'; @override String downloadedAlbumDeleteCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + one: 'трек', + many: 'треков', + few: 'трека', ); - return 'Delete $count $_temp0'; + return 'Удалить $count $_temp0'; } @override - String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + String get downloadedAlbumSelectToDelete => 'Выберите треки для удаления'; @override - String get utilityFunctions => 'Utility Functions'; + String get utilityFunctions => 'Функции утилиты'; @override - String get recentTypeArtist => 'Artist'; + String get recentTypeArtist => 'Исполнитель'; @override - String get recentTypeAlbum => 'Album'; + String get recentTypeAlbum => 'Альбом'; @override - String get recentTypeSong => 'Song'; + String get recentTypeSong => 'Песня'; @override - String get recentTypePlaylist => 'Playlist'; + String get recentTypePlaylist => 'Плейлист'; @override String recentPlaylistInfo(String name) { - return 'Playlist: $name'; + return 'Плейлист: $name'; } @override String errorGeneric(String message) { - return 'Error: $message'; + return 'Ошибка: $message'; } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a5ab5a63..83bbd344 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2508,6 +2508,14 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -3962,6 +3970,28 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -4473,6 +4503,14 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -5388,6 +5426,15 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get sectionLayout => 'Layout'; + @override + String get sectionLanguage => 'Language'; + + @override + String get appearanceLanguage => 'App Language'; + + @override + String get appearanceLanguageSubtitle => 'Choose your preferred language'; + @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -5918,4 +5965,26 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index 7e545a0b..5b9f70aa 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh-CN", + "@@locale": "zh_CN", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 8526e88f..fdec8f45 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh-TW", + "@@locale": "zh_TW", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { From 5c675535965d7c45d9b888e921d9e4af752cf6da Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:20 +0700 Subject: [PATCH 02/48] New translations app_en.arb (French) --- lib/l10n/arb/app_fr.arb | 68 ++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 7de05fc5..3e5f8961 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -642,6 +642,20 @@ } } }, + "artistPopular": "Popular", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} monthly listeners", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, "trackMetadataTitle": "Track Info", "@trackMetadataTitle": { "description": "Track metadata screen title" @@ -1851,27 +1865,15 @@ }, "sectionLanguage": "Language", "@sectionLanguage": { - "description": "Settings section header for language selection" + "description": "Settings section header for language" }, "appearanceLanguage": "App Language", "@appearanceLanguage": { - "description": "Setting title for language selection" + "description": "Language setting title" }, "appearanceLanguageSubtitle": "Choose your preferred language", "@appearanceLanguageSubtitle": { - "description": "Subtitle for language setting" - }, - "languageSystem": "System Default", - "@languageSystem": { - "description": "Use device system language" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" + "description": "Language setting subtitle" }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { @@ -2573,5 +2575,41 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" + }, + "recentTypeArtist": "Artist", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Song", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": { + "type": "String", + "description": "Error message" + } + } } } \ No newline at end of file From cc12f63d36fb867f8bc5a6e88198e3c7c938a58d Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:21 +0700 Subject: [PATCH 03/48] New translations app_en.arb (Spanish) --- lib/l10n/arb/app_es-ES.arb | 2615 ++++++++++++++++++++++++++++++++++++ 1 file changed, 2615 insertions(+) create mode 100644 lib/l10n/arb/app_es-ES.arb diff --git a/lib/l10n/arb/app_es-ES.arb b/lib/l10n/arb/app_es-ES.arb new file mode 100644 index 00000000..cf4def73 --- /dev/null +++ b/lib/l10n/arb/app_es-ES.arb @@ -0,0 +1,2615 @@ +{ + "@@locale": "es-ES", + "@@last_modified": "2026-01-16", + "appName": "SpotiFLAC", + "@appName": { + "description": "App name - DO NOT TRANSLATE" + }, + "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "@appDescription": { + "description": "App description shown in about page" + }, + "navHome": "Home", + "@navHome": { + "description": "Bottom navigation - Home tab" + }, + "navHistory": "History", + "@navHistory": { + "description": "Bottom navigation - History tab" + }, + "navSettings": "Settings", + "@navSettings": { + "description": "Bottom navigation - Settings tab" + }, + "navStore": "Store", + "@navStore": { + "description": "Bottom navigation - Extension store tab" + }, + "homeTitle": "Home", + "@homeTitle": { + "description": "Home screen title" + }, + "homeSearchHint": "Paste Spotify URL or search...", + "@homeSearchHint": { + "description": "Placeholder text in search box" + }, + "homeSearchHintExtension": "Search with {extensionName}...", + "@homeSearchHintExtension": { + "description": "Placeholder when extension search is active", + "placeholders": { + "extensionName": { + "type": "String", + "description": "Name of the active extension" + } + } + }, + "homeSubtitle": "Paste a Spotify link or search by name", + "@homeSubtitle": { + "description": "Subtitle shown below search box" + }, + "homeSupports": "Supports: Track, Album, Playlist, Artist URLs", + "@homeSupports": { + "description": "Info text about supported URL types" + }, + "homeRecent": "Recent", + "@homeRecent": { + "description": "Section header for recent searches" + }, + "historyTitle": "History", + "@historyTitle": { + "description": "History screen title" + }, + "historyDownloading": "Downloading ({count})", + "@historyDownloading": { + "description": "Tab showing active downloads count", + "placeholders": { + "count": { + "type": "int", + "description": "Number of active downloads" + } + } + }, + "historyDownloaded": "Downloaded", + "@historyDownloaded": { + "description": "Tab showing completed downloads" + }, + "historyFilterAll": "All", + "@historyFilterAll": { + "description": "Filter chip - show all items" + }, + "historyFilterAlbums": "Albums", + "@historyFilterAlbums": { + "description": "Filter chip - show albums only" + }, + "historyFilterSingles": "Singles", + "@historyFilterSingles": { + "description": "Filter chip - show singles only" + }, + "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "@historyTracksCount": { + "description": "Track count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", + "@historyAlbumsCount": { + "description": "Album count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "historyNoDownloads": "No download history", + "@historyNoDownloads": { + "description": "Empty state title" + }, + "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", + "@historyNoDownloadsSubtitle": { + "description": "Empty state subtitle" + }, + "historyNoAlbums": "No album downloads", + "@historyNoAlbums": { + "description": "Empty state when filtering albums" + }, + "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", + "@historyNoAlbumsSubtitle": { + "description": "Empty state subtitle for albums filter" + }, + "historyNoSingles": "No single downloads", + "@historyNoSingles": { + "description": "Empty state when filtering singles" + }, + "historyNoSinglesSubtitle": "Single track downloads will appear here", + "@historyNoSinglesSubtitle": { + "description": "Empty state subtitle for singles filter" + }, + "settingsTitle": "Settings", + "@settingsTitle": { + "description": "Settings screen title" + }, + "settingsDownload": "Download", + "@settingsDownload": { + "description": "Settings section - download options" + }, + "settingsAppearance": "Appearance", + "@settingsAppearance": { + "description": "Settings section - visual customization" + }, + "settingsOptions": "Options", + "@settingsOptions": { + "description": "Settings section - app options" + }, + "settingsExtensions": "Extensions", + "@settingsExtensions": { + "description": "Settings section - extension management" + }, + "settingsAbout": "About", + "@settingsAbout": { + "description": "Settings section - app info" + }, + "downloadTitle": "Download", + "@downloadTitle": { + "description": "Download settings page title" + }, + "downloadLocation": "Download Location", + "@downloadLocation": { + "description": "Setting for download folder" + }, + "downloadLocationSubtitle": "Choose where to save files", + "@downloadLocationSubtitle": { + "description": "Subtitle for download location" + }, + "downloadLocationDefault": "Default location", + "@downloadLocationDefault": { + "description": "Shown when using default folder" + }, + "downloadDefaultService": "Default Service", + "@downloadDefaultService": { + "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" + }, + "downloadDefaultServiceSubtitle": "Service used for downloads", + "@downloadDefaultServiceSubtitle": { + "description": "Subtitle for default service" + }, + "downloadDefaultQuality": "Default Quality", + "@downloadDefaultQuality": { + "description": "Setting for audio quality" + }, + "downloadAskQuality": "Ask Quality Before Download", + "@downloadAskQuality": { + "description": "Toggle to show quality picker" + }, + "downloadAskQualitySubtitle": "Show quality picker for each download", + "@downloadAskQualitySubtitle": { + "description": "Subtitle for ask quality toggle" + }, + "downloadFilenameFormat": "Filename Format", + "@downloadFilenameFormat": { + "description": "Setting for output filename pattern" + }, + "downloadFolderOrganization": "Folder Organization", + "@downloadFolderOrganization": { + "description": "Setting for folder structure" + }, + "downloadSeparateSingles": "Separate Singles", + "@downloadSeparateSingles": { + "description": "Toggle to separate single tracks" + }, + "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", + "@downloadSeparateSinglesSubtitle": { + "description": "Subtitle for separate singles toggle" + }, + "qualityBest": "Best Available", + "@qualityBest": { + "description": "Audio quality option - highest available" + }, + "qualityFlac": "FLAC", + "@qualityFlac": { + "description": "Audio quality option - FLAC lossless" + }, + "quality320": "320 kbps", + "@quality320": { + "description": "Audio quality option - 320kbps MP3" + }, + "quality128": "128 kbps", + "@quality128": { + "description": "Audio quality option - 128kbps MP3" + }, + "appearanceTitle": "Appearance", + "@appearanceTitle": { + "description": "Appearance settings page title" + }, + "appearanceTheme": "Theme", + "@appearanceTheme": { + "description": "Theme mode setting" + }, + "appearanceThemeSystem": "System", + "@appearanceThemeSystem": { + "description": "Follow system theme" + }, + "appearanceThemeLight": "Light", + "@appearanceThemeLight": { + "description": "Light theme" + }, + "appearanceThemeDark": "Dark", + "@appearanceThemeDark": { + "description": "Dark theme" + }, + "appearanceDynamicColor": "Dynamic Color", + "@appearanceDynamicColor": { + "description": "Material You dynamic colors" + }, + "appearanceDynamicColorSubtitle": "Use colors from your wallpaper", + "@appearanceDynamicColorSubtitle": { + "description": "Subtitle for dynamic color" + }, + "appearanceAccentColor": "Accent Color", + "@appearanceAccentColor": { + "description": "Custom accent color picker" + }, + "appearanceHistoryView": "History View", + "@appearanceHistoryView": { + "description": "Layout style for history" + }, + "appearanceHistoryViewList": "List", + "@appearanceHistoryViewList": { + "description": "List layout option" + }, + "appearanceHistoryViewGrid": "Grid", + "@appearanceHistoryViewGrid": { + "description": "Grid layout option" + }, + "optionsTitle": "Options", + "@optionsTitle": { + "description": "Options settings page title" + }, + "optionsSearchSource": "Search Source", + "@optionsSearchSource": { + "description": "Section for search provider settings" + }, + "optionsPrimaryProvider": "Primary Provider", + "@optionsPrimaryProvider": { + "description": "Main search provider setting" + }, + "optionsPrimaryProviderSubtitle": "Service used when searching by track name.", + "@optionsPrimaryProviderSubtitle": { + "description": "Subtitle for primary provider" + }, + "optionsUsingExtension": "Using extension: {extensionName}", + "@optionsUsingExtension": { + "description": "Shows active extension name", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", + "@optionsSwitchBack": { + "description": "Hint to switch back to built-in providers" + }, + "optionsAutoFallback": "Auto Fallback", + "@optionsAutoFallback": { + "description": "Auto-retry with other services" + }, + "optionsAutoFallbackSubtitle": "Try other services if download fails", + "@optionsAutoFallbackSubtitle": { + "description": "Subtitle for auto fallback" + }, + "optionsUseExtensionProviders": "Use Extension Providers", + "@optionsUseExtensionProviders": { + "description": "Enable extension download providers" + }, + "optionsUseExtensionProvidersOn": "Extensions will be tried first", + "@optionsUseExtensionProvidersOn": { + "description": "Status when extension providers enabled" + }, + "optionsUseExtensionProvidersOff": "Using built-in providers only", + "@optionsUseExtensionProvidersOff": { + "description": "Status when extension providers disabled" + }, + "optionsEmbedLyrics": "Embed Lyrics", + "@optionsEmbedLyrics": { + "description": "Embed lyrics in audio files" + }, + "optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files", + "@optionsEmbedLyricsSubtitle": { + "description": "Subtitle for embed lyrics" + }, + "optionsMaxQualityCover": "Max Quality Cover", + "@optionsMaxQualityCover": { + "description": "Download highest quality album art" + }, + "optionsMaxQualityCoverSubtitle": "Download highest resolution cover art", + "@optionsMaxQualityCoverSubtitle": { + "description": "Subtitle for max quality cover" + }, + "optionsConcurrentDownloads": "Concurrent Downloads", + "@optionsConcurrentDownloads": { + "description": "Number of parallel downloads" + }, + "optionsConcurrentSequential": "Sequential (1 at a time)", + "@optionsConcurrentSequential": { + "description": "Download one at a time" + }, + "optionsConcurrentParallel": "{count} parallel downloads", + "@optionsConcurrentParallel": { + "description": "Multiple parallel downloads", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "optionsConcurrentWarning": "Parallel downloads may trigger rate limiting", + "@optionsConcurrentWarning": { + "description": "Warning about rate limits" + }, + "optionsExtensionStore": "Extension Store", + "@optionsExtensionStore": { + "description": "Show/hide store tab" + }, + "optionsExtensionStoreSubtitle": "Show Store tab in navigation", + "@optionsExtensionStoreSubtitle": { + "description": "Subtitle for extension store toggle" + }, + "optionsCheckUpdates": "Check for Updates", + "@optionsCheckUpdates": { + "description": "Auto update check toggle" + }, + "optionsCheckUpdatesSubtitle": "Notify when new version is available", + "@optionsCheckUpdatesSubtitle": { + "description": "Subtitle for update check" + }, + "optionsUpdateChannel": "Update Channel", + "@optionsUpdateChannel": { + "description": "Stable vs preview releases" + }, + "optionsUpdateChannelStable": "Stable releases only", + "@optionsUpdateChannelStable": { + "description": "Only stable updates" + }, + "optionsUpdateChannelPreview": "Get preview releases", + "@optionsUpdateChannelPreview": { + "description": "Include beta/preview updates" + }, + "optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features", + "@optionsUpdateChannelWarning": { + "description": "Warning about preview channel" + }, + "optionsClearHistory": "Clear Download History", + "@optionsClearHistory": { + "description": "Delete all download history" + }, + "optionsClearHistorySubtitle": "Remove all downloaded tracks from history", + "@optionsClearHistorySubtitle": { + "description": "Subtitle for clear history" + }, + "optionsDetailedLogging": "Detailed Logging", + "@optionsDetailedLogging": { + "description": "Enable verbose logs for debugging" + }, + "optionsDetailedLoggingOn": "Detailed logs are being recorded", + "@optionsDetailedLoggingOn": { + "description": "Status when logging enabled" + }, + "optionsDetailedLoggingOff": "Enable for bug reports", + "@optionsDetailedLoggingOff": { + "description": "Status when logging disabled" + }, + "optionsSpotifyCredentials": "Spotify Credentials", + "@optionsSpotifyCredentials": { + "description": "Spotify API credentials setting" + }, + "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", + "@optionsSpotifyCredentialsConfigured": { + "description": "Shows configured client ID preview", + "placeholders": { + "clientId": { + "type": "String" + } + } + }, + "optionsSpotifyCredentialsRequired": "Required - tap to configure", + "@optionsSpotifyCredentialsRequired": { + "description": "Prompt to set up credentials" + }, + "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", + "@optionsSpotifyWarning": { + "description": "Info about Spotify API requirement" + }, + "extensionsTitle": "Extensions", + "@extensionsTitle": { + "description": "Extensions page title" + }, + "extensionsInstalled": "Installed Extensions", + "@extensionsInstalled": { + "description": "Section header for installed extensions" + }, + "extensionsNone": "No extensions installed", + "@extensionsNone": { + "description": "Empty state title" + }, + "extensionsNoneSubtitle": "Install extensions from the Store tab", + "@extensionsNoneSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsEnabled": "Enabled", + "@extensionsEnabled": { + "description": "Extension status - active" + }, + "extensionsDisabled": "Disabled", + "@extensionsDisabled": { + "description": "Extension status - inactive" + }, + "extensionsVersion": "Version {version}", + "@extensionsVersion": { + "description": "Extension version display", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "extensionsAuthor": "by {author}", + "@extensionsAuthor": { + "description": "Extension author credit", + "placeholders": { + "author": { + "type": "String" + } + } + }, + "extensionsUninstall": "Uninstall", + "@extensionsUninstall": { + "description": "Uninstall extension button" + }, + "extensionsSetAsSearch": "Set as Search Provider", + "@extensionsSetAsSearch": { + "description": "Use extension for search" + }, + "storeTitle": "Extension Store", + "@storeTitle": { + "description": "Store screen title" + }, + "storeSearch": "Search extensions...", + "@storeSearch": { + "description": "Store search placeholder" + }, + "storeInstall": "Install", + "@storeInstall": { + "description": "Install extension button" + }, + "storeInstalled": "Installed", + "@storeInstalled": { + "description": "Already installed badge" + }, + "storeUpdate": "Update", + "@storeUpdate": { + "description": "Update available button" + }, + "aboutTitle": "About", + "@aboutTitle": { + "description": "About page title" + }, + "aboutContributors": "Contributors", + "@aboutContributors": { + "description": "Section for contributors" + }, + "aboutMobileDeveloper": "Mobile version developer", + "@aboutMobileDeveloper": { + "description": "Role description for mobile dev" + }, + "aboutOriginalCreator": "Creator of the original SpotiFLAC", + "@aboutOriginalCreator": { + "description": "Role description for original creator" + }, + "aboutLogoArtist": "The talented artist who created our beautiful app logo!", + "@aboutLogoArtist": { + "description": "Role description for logo artist" + }, + "aboutSpecialThanks": "Special Thanks", + "@aboutSpecialThanks": { + "description": "Section for special thanks" + }, + "aboutLinks": "Links", + "@aboutLinks": { + "description": "Section for external links" + }, + "aboutMobileSource": "Mobile source code", + "@aboutMobileSource": { + "description": "Link to mobile GitHub repo" + }, + "aboutPCSource": "PC source code", + "@aboutPCSource": { + "description": "Link to PC GitHub repo" + }, + "aboutReportIssue": "Report an issue", + "@aboutReportIssue": { + "description": "Link to report bugs" + }, + "aboutReportIssueSubtitle": "Report any problems you encounter", + "@aboutReportIssueSubtitle": { + "description": "Subtitle for report issue" + }, + "aboutFeatureRequest": "Feature request", + "@aboutFeatureRequest": { + "description": "Link to suggest features" + }, + "aboutFeatureRequestSubtitle": "Suggest new features for the app", + "@aboutFeatureRequestSubtitle": { + "description": "Subtitle for feature request" + }, + "aboutSupport": "Support", + "@aboutSupport": { + "description": "Section for support/donation links" + }, + "aboutBuyMeCoffee": "Buy me a coffee", + "@aboutBuyMeCoffee": { + "description": "Donation link" + }, + "aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi", + "@aboutBuyMeCoffeeSubtitle": { + "description": "Subtitle for donation" + }, + "aboutApp": "App", + "@aboutApp": { + "description": "Section for app info" + }, + "aboutVersion": "Version", + "@aboutVersion": { + "description": "Version info label" + }, + "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", + "@aboutBinimumDesc": { + "description": "Credit description for binimum" + }, + "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", + "@aboutSachinsenalDesc": { + "description": "Credit description for sachinsenal0x64" + }, + "aboutDoubleDouble": "DoubleDouble", + "@aboutDoubleDouble": { + "description": "Name of Amazon API service - DO NOT TRANSLATE" + }, + "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", + "@aboutDoubleDoubleDesc": { + "description": "Credit for DoubleDouble API" + }, + "aboutDabMusic": "DAB Music", + "@aboutDabMusic": { + "description": "Name of Qobuz API service - DO NOT TRANSLATE" + }, + "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", + "@aboutDabMusicDesc": { + "description": "Credit for DAB Music API" + }, + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "@aboutAppDescription": { + "description": "App description in header card" + }, + "albumTitle": "Album", + "@albumTitle": { + "description": "Album screen title" + }, + "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", + "@albumTracks": { + "description": "Album track count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "albumDownloadAll": "Download All", + "@albumDownloadAll": { + "description": "Button to download all tracks" + }, + "albumDownloadRemaining": "Download Remaining", + "@albumDownloadRemaining": { + "description": "Button to download remaining tracks" + }, + "playlistTitle": "Playlist", + "@playlistTitle": { + "description": "Playlist screen title" + }, + "artistTitle": "Artist", + "@artistTitle": { + "description": "Artist screen title" + }, + "artistAlbums": "Albums", + "@artistAlbums": { + "description": "Section header for artist albums" + }, + "artistSingles": "Singles & EPs", + "@artistSingles": { + "description": "Section header for singles/EPs" + }, + "artistCompilations": "Compilations", + "@artistCompilations": { + "description": "Section header for compilations" + }, + "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", + "@artistReleases": { + "description": "Artist release count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "artistPopular": "Popular", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} monthly listeners", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, + "trackMetadataTitle": "Track Info", + "@trackMetadataTitle": { + "description": "Track metadata screen title" + }, + "trackMetadataArtist": "Artist", + "@trackMetadataArtist": { + "description": "Metadata field - artist name" + }, + "trackMetadataAlbum": "Album", + "@trackMetadataAlbum": { + "description": "Metadata field - album name" + }, + "trackMetadataDuration": "Duration", + "@trackMetadataDuration": { + "description": "Metadata field - track length" + }, + "trackMetadataQuality": "Quality", + "@trackMetadataQuality": { + "description": "Metadata field - audio quality" + }, + "trackMetadataPath": "File Path", + "@trackMetadataPath": { + "description": "Metadata field - file location" + }, + "trackMetadataDownloadedAt": "Downloaded", + "@trackMetadataDownloadedAt": { + "description": "Metadata field - download date" + }, + "trackMetadataService": "Service", + "@trackMetadataService": { + "description": "Metadata field - download service used" + }, + "trackMetadataPlay": "Play", + "@trackMetadataPlay": { + "description": "Action button - play track" + }, + "trackMetadataShare": "Share", + "@trackMetadataShare": { + "description": "Action button - share track" + }, + "trackMetadataDelete": "Delete", + "@trackMetadataDelete": { + "description": "Action button - delete track" + }, + "trackMetadataRedownload": "Re-download", + "@trackMetadataRedownload": { + "description": "Action button - download again" + }, + "trackMetadataOpenFolder": "Open Folder", + "@trackMetadataOpenFolder": { + "description": "Action button - open containing folder" + }, + "setupTitle": "Welcome to SpotiFLAC", + "@setupTitle": { + "description": "Setup wizard title" + }, + "setupSubtitle": "Let's get you started", + "@setupSubtitle": { + "description": "Setup wizard subtitle" + }, + "setupStoragePermission": "Storage Permission", + "@setupStoragePermission": { + "description": "Storage permission step title" + }, + "setupStoragePermissionSubtitle": "Required to save downloaded files", + "@setupStoragePermissionSubtitle": { + "description": "Explanation for storage permission" + }, + "setupStoragePermissionGranted": "Permission granted", + "@setupStoragePermissionGranted": { + "description": "Status when permission granted" + }, + "setupStoragePermissionDenied": "Permission denied", + "@setupStoragePermissionDenied": { + "description": "Status when permission denied" + }, + "setupGrantPermission": "Grant Permission", + "@setupGrantPermission": { + "description": "Button to request permission" + }, + "setupDownloadLocation": "Download Location", + "@setupDownloadLocation": { + "description": "Download folder step title" + }, + "setupChooseFolder": "Choose Folder", + "@setupChooseFolder": { + "description": "Button to pick folder" + }, + "setupContinue": "Continue", + "@setupContinue": { + "description": "Continue to next step button" + }, + "setupSkip": "Skip for now", + "@setupSkip": { + "description": "Skip current step button" + }, + "setupStorageAccessRequired": "Storage Access Required", + "@setupStorageAccessRequired": { + "description": "Title when storage access needed" + }, + "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", + "@setupStorageAccessMessage": { + "description": "Explanation for storage access" + }, + "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", + "@setupStorageAccessMessageAndroid11": { + "description": "Android 11+ specific explanation" + }, + "setupOpenSettings": "Open Settings", + "@setupOpenSettings": { + "description": "Button to open system settings" + }, + "setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.", + "@setupPermissionDeniedMessage": { + "description": "Error when permission denied" + }, + "setupPermissionRequired": "{permissionType} Permission Required", + "@setupPermissionRequired": { + "description": "Generic permission required title", + "placeholders": { + "permissionType": { + "type": "String", + "description": "Type of permission (Storage/Notification)" + } + } + }, + "setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.", + "@setupPermissionRequiredMessage": { + "description": "Generic permission required message", + "placeholders": { + "permissionType": { + "type": "String" + } + } + }, + "setupSelectDownloadFolder": "Select Download Folder", + "@setupSelectDownloadFolder": { + "description": "Folder selection step title" + }, + "setupUseDefaultFolder": "Use Default Folder?", + "@setupUseDefaultFolder": { + "description": "Dialog title for default folder" + }, + "setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?", + "@setupNoFolderSelected": { + "description": "Prompt when no folder selected" + }, + "setupUseDefault": "Use Default", + "@setupUseDefault": { + "description": "Button to use default folder" + }, + "setupDownloadLocationTitle": "Download Location", + "@setupDownloadLocationTitle": { + "description": "Download location dialog title" + }, + "setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.", + "@setupDownloadLocationIosMessage": { + "description": "iOS-specific folder info" + }, + "setupAppDocumentsFolder": "App Documents Folder", + "@setupAppDocumentsFolder": { + "description": "iOS documents folder option" + }, + "setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app", + "@setupAppDocumentsFolderSubtitle": { + "description": "Subtitle for documents folder" + }, + "setupChooseFromFiles": "Choose from Files", + "@setupChooseFromFiles": { + "description": "iOS file picker option" + }, + "setupChooseFromFilesSubtitle": "Select iCloud or other location", + "@setupChooseFromFilesSubtitle": { + "description": "Subtitle for file picker" + }, + "setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", + "@setupIosEmptyFolderWarning": { + "description": "iOS folder selection warning" + }, + "setupDownloadInFlac": "Download Spotify tracks in FLAC", + "@setupDownloadInFlac": { + "description": "App tagline in setup" + }, + "setupStepStorage": "Storage", + "@setupStepStorage": { + "description": "Setup step indicator - storage" + }, + "setupStepNotification": "Notification", + "@setupStepNotification": { + "description": "Setup step indicator - notification" + }, + "setupStepFolder": "Folder", + "@setupStepFolder": { + "description": "Setup step indicator - folder" + }, + "setupStepSpotify": "Spotify", + "@setupStepSpotify": { + "description": "Setup step indicator - Spotify API" + }, + "setupStepPermission": "Permission", + "@setupStepPermission": { + "description": "Setup step indicator - permission" + }, + "setupStorageGranted": "Storage Permission Granted!", + "@setupStorageGranted": { + "description": "Success message for storage permission" + }, + "setupStorageRequired": "Storage Permission Required", + "@setupStorageRequired": { + "description": "Title when storage permission needed" + }, + "setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", + "@setupStorageDescription": { + "description": "Explanation for storage permission" + }, + "setupNotificationGranted": "Notification Permission Granted!", + "@setupNotificationGranted": { + "description": "Success message for notification permission" + }, + "setupNotificationEnable": "Enable Notifications", + "@setupNotificationEnable": { + "description": "Button to enable notifications" + }, + "setupNotificationDescription": "Get notified when downloads complete or require attention.", + "@setupNotificationDescription": { + "description": "Explanation for notifications" + }, + "setupFolderSelected": "Download Folder Selected!", + "@setupFolderSelected": { + "description": "Success message for folder selection" + }, + "setupFolderChoose": "Choose Download Folder", + "@setupFolderChoose": { + "description": "Button to choose folder" + }, + "setupFolderDescription": "Select a folder where your downloaded music will be saved.", + "@setupFolderDescription": { + "description": "Explanation for folder selection" + }, + "setupChangeFolder": "Change Folder", + "@setupChangeFolder": { + "description": "Button to change selected folder" + }, + "setupSelectFolder": "Select Folder", + "@setupSelectFolder": { + "description": "Button to select folder" + }, + "setupSpotifyApiOptional": "Spotify API (Optional)", + "@setupSpotifyApiOptional": { + "description": "Spotify API step title" + }, + "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", + "@setupSpotifyApiDescription": { + "description": "Explanation for Spotify API" + }, + "setupUseSpotifyApi": "Use Spotify API", + "@setupUseSpotifyApi": { + "description": "Toggle to enable Spotify API" + }, + "setupEnterCredentialsBelow": "Enter your credentials below", + "@setupEnterCredentialsBelow": { + "description": "Prompt to enter credentials" + }, + "setupUsingDeezer": "Using Deezer (no account needed)", + "@setupUsingDeezer": { + "description": "Status when using Deezer" + }, + "setupEnterClientId": "Enter Spotify Client ID", + "@setupEnterClientId": { + "description": "Placeholder for client ID field" + }, + "setupEnterClientSecret": "Enter Spotify Client Secret", + "@setupEnterClientSecret": { + "description": "Placeholder for client secret field" + }, + "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", + "@setupGetFreeCredentials": { + "description": "Info about getting Spotify credentials" + }, + "setupEnableNotifications": "Enable Notifications", + "@setupEnableNotifications": { + "description": "Button to enable notifications" + }, + "setupProceedToNextStep": "You can now proceed to the next step.", + "@setupProceedToNextStep": { + "description": "Message after completing a step" + }, + "setupNotificationProgressDescription": "You will receive download progress notifications.", + "@setupNotificationProgressDescription": { + "description": "Info about notification usage" + }, + "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", + "@setupNotificationBackgroundDescription": { + "description": "Detailed notification explanation" + }, + "setupSkipForNow": "Skip for now", + "@setupSkipForNow": { + "description": "Skip button text" + }, + "setupBack": "Back", + "@setupBack": { + "description": "Back button text" + }, + "setupNext": "Next", + "@setupNext": { + "description": "Next button text" + }, + "setupGetStarted": "Get Started", + "@setupGetStarted": { + "description": "Final setup button" + }, + "setupSkipAndStart": "Skip & Start", + "@setupSkipAndStart": { + "description": "Skip setup and start app" + }, + "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", + "@setupAllowAccessToManageFiles": { + "description": "Instruction for file access permission" + }, + "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", + "@setupGetCredentialsFromSpotify": { + "description": "Link text for Spotify developer portal" + }, + "dialogCancel": "Cancel", + "@dialogCancel": { + "description": "Dialog button - cancel action" + }, + "dialogOk": "OK", + "@dialogOk": { + "description": "Dialog button - confirm/acknowledge" + }, + "dialogSave": "Save", + "@dialogSave": { + "description": "Dialog button - save changes" + }, + "dialogDelete": "Delete", + "@dialogDelete": { + "description": "Dialog button - delete item" + }, + "dialogRetry": "Retry", + "@dialogRetry": { + "description": "Dialog button - retry action" + }, + "dialogClose": "Close", + "@dialogClose": { + "description": "Dialog button - close dialog" + }, + "dialogYes": "Yes", + "@dialogYes": { + "description": "Dialog button - confirm yes" + }, + "dialogNo": "No", + "@dialogNo": { + "description": "Dialog button - confirm no" + }, + "dialogClear": "Clear", + "@dialogClear": { + "description": "Dialog button - clear items" + }, + "dialogConfirm": "Confirm", + "@dialogConfirm": { + "description": "Dialog button - confirm action" + }, + "dialogDone": "Done", + "@dialogDone": { + "description": "Dialog button - action completed" + }, + "dialogImport": "Import", + "@dialogImport": { + "description": "Dialog button - import data" + }, + "dialogDiscard": "Discard", + "@dialogDiscard": { + "description": "Dialog button - discard changes" + }, + "dialogRemove": "Remove", + "@dialogRemove": { + "description": "Dialog button - remove item" + }, + "dialogUninstall": "Uninstall", + "@dialogUninstall": { + "description": "Dialog button - uninstall extension" + }, + "dialogDiscardChanges": "Discard Changes?", + "@dialogDiscardChanges": { + "description": "Dialog title - unsaved changes warning" + }, + "dialogUnsavedChanges": "You have unsaved changes. Do you want to discard them?", + "@dialogUnsavedChanges": { + "description": "Dialog message - unsaved changes" + }, + "dialogDownloadFailed": "Download Failed", + "@dialogDownloadFailed": { + "description": "Dialog title - download error" + }, + "dialogTrackLabel": "Track:", + "@dialogTrackLabel": { + "description": "Label for track name in error dialog" + }, + "dialogArtistLabel": "Artist:", + "@dialogArtistLabel": { + "description": "Label for artist name in error dialog" + }, + "dialogErrorLabel": "Error:", + "@dialogErrorLabel": { + "description": "Label for error message" + }, + "dialogClearAll": "Clear All", + "@dialogClearAll": { + "description": "Dialog title - clear all items" + }, + "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", + "@dialogClearAllDownloads": { + "description": "Dialog message - clear downloads confirmation" + }, + "dialogRemoveFromDevice": "Remove from device?", + "@dialogRemoveFromDevice": { + "description": "Dialog title - delete file confirmation" + }, + "dialogRemoveExtension": "Remove Extension", + "@dialogRemoveExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.", + "@dialogRemoveExtensionMessage": { + "description": "Dialog message - uninstall confirmation" + }, + "dialogUninstallExtension": "Uninstall Extension?", + "@dialogUninstallExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?", + "@dialogUninstallExtensionMessage": { + "description": "Dialog message - uninstall specific extension", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "dialogClearHistoryTitle": "Clear History", + "@dialogClearHistoryTitle": { + "description": "Dialog title - clear download history" + }, + "dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.", + "@dialogClearHistoryMessage": { + "description": "Dialog message - clear history confirmation" + }, + "dialogDeleteSelectedTitle": "Delete Selected", + "@dialogDeleteSelectedTitle": { + "description": "Dialog title - delete selected items" + }, + "dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.", + "@dialogDeleteSelectedMessage": { + "description": "Dialog message - delete selected tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dialogImportPlaylistTitle": "Import Playlist", + "@dialogImportPlaylistTitle": { + "description": "Dialog title - import CSV playlist" + }, + "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", + "@dialogImportPlaylistMessage": { + "description": "Dialog message - import playlist confirmation", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAddedToQueue": "Added \"{trackName}\" to queue", + "@snackbarAddedToQueue": { + "description": "Snackbar - track added to download queue", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarAddedTracksToQueue": "Added {count} tracks to queue", + "@snackbarAddedTracksToQueue": { + "description": "Snackbar - multiple tracks added to queue", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded", + "@snackbarAlreadyDownloaded": { + "description": "Snackbar - track already exists", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarHistoryCleared": "History cleared", + "@snackbarHistoryCleared": { + "description": "Snackbar - history deleted" + }, + "snackbarCredentialsSaved": "Credentials saved", + "@snackbarCredentialsSaved": { + "description": "Snackbar - Spotify credentials saved" + }, + "snackbarCredentialsCleared": "Credentials cleared", + "@snackbarCredentialsCleared": { + "description": "Snackbar - Spotify credentials removed" + }, + "snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}", + "@snackbarDeletedTracks": { + "description": "Snackbar - tracks deleted", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarCannotOpenFile": "Cannot open file: {error}", + "@snackbarCannotOpenFile": { + "description": "Snackbar - file open error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarFillAllFields": "Please fill all fields", + "@snackbarFillAllFields": { + "description": "Snackbar - validation error" + }, + "snackbarViewQueue": "View Queue", + "@snackbarViewQueue": { + "description": "Snackbar action - view download queue" + }, + "snackbarFailedToLoad": "Failed to load: {error}", + "@snackbarFailedToLoad": { + "description": "Snackbar - loading error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarUrlCopied": "{platform} URL copied to clipboard", + "@snackbarUrlCopied": { + "description": "Snackbar - URL copied", + "placeholders": { + "platform": { + "type": "String", + "description": "Platform name (Spotify/Deezer)" + } + } + }, + "snackbarFileNotFound": "File not found", + "@snackbarFileNotFound": { + "description": "Snackbar - file doesn't exist" + }, + "snackbarSelectExtFile": "Please select a .spotiflac-ext file", + "@snackbarSelectExtFile": { + "description": "Snackbar - wrong file type selected" + }, + "snackbarProviderPrioritySaved": "Provider priority saved", + "@snackbarProviderPrioritySaved": { + "description": "Snackbar - provider order saved" + }, + "snackbarMetadataProviderSaved": "Metadata provider priority saved", + "@snackbarMetadataProviderSaved": { + "description": "Snackbar - metadata provider order saved" + }, + "snackbarExtensionInstalled": "{extensionName} installed.", + "@snackbarExtensionInstalled": { + "description": "Snackbar - extension installed successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarExtensionUpdated": "{extensionName} updated.", + "@snackbarExtensionUpdated": { + "description": "Snackbar - extension updated successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarFailedToInstall": "Failed to install extension", + "@snackbarFailedToInstall": { + "description": "Snackbar - extension install error" + }, + "snackbarFailedToUpdate": "Failed to update extension", + "@snackbarFailedToUpdate": { + "description": "Snackbar - extension update error" + }, + "errorRateLimited": "Rate Limited", + "@errorRateLimited": { + "description": "Error title - too many requests" + }, + "errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", + "@errorRateLimitedMessage": { + "description": "Error message - rate limit explanation" + }, + "errorFailedToLoad": "Failed to load {item}", + "@errorFailedToLoad": { + "description": "Error message - loading failed", + "placeholders": { + "item": { + "type": "String", + "description": "Item that failed to load (album/playlist/etc)" + } + } + }, + "errorNoTracksFound": "No tracks found", + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, + "errorMissingExtensionSource": "Cannot load {item}: missing extension source", + "@errorMissingExtensionSource": { + "description": "Error - extension source not available", + "placeholders": { + "item": { + "type": "String" + } + } + }, + "statusQueued": "Queued", + "@statusQueued": { + "description": "Download status - waiting in queue" + }, + "statusDownloading": "Downloading", + "@statusDownloading": { + "description": "Download status - in progress" + }, + "statusFinalizing": "Finalizing", + "@statusFinalizing": { + "description": "Download status - writing metadata" + }, + "statusCompleted": "Completed", + "@statusCompleted": { + "description": "Download status - finished" + }, + "statusFailed": "Failed", + "@statusFailed": { + "description": "Download status - error occurred" + }, + "statusSkipped": "Skipped", + "@statusSkipped": { + "description": "Download status - already exists" + }, + "statusPaused": "Paused", + "@statusPaused": { + "description": "Download status - paused" + }, + "actionPause": "Pause", + "@actionPause": { + "description": "Action button - pause download" + }, + "actionResume": "Resume", + "@actionResume": { + "description": "Action button - resume download" + }, + "actionCancel": "Cancel", + "@actionCancel": { + "description": "Action button - cancel operation" + }, + "actionStop": "Stop", + "@actionStop": { + "description": "Action button - stop operation" + }, + "actionSelect": "Select", + "@actionSelect": { + "description": "Action button - enter selection mode" + }, + "actionSelectAll": "Select All", + "@actionSelectAll": { + "description": "Action button - select all items" + }, + "actionDeselect": "Deselect", + "@actionDeselect": { + "description": "Action button - deselect all" + }, + "actionPaste": "Paste", + "@actionPaste": { + "description": "Action button - paste from clipboard" + }, + "actionImportCsv": "Import CSV", + "@actionImportCsv": { + "description": "Action button - import CSV file" + }, + "actionRemoveCredentials": "Remove Credentials", + "@actionRemoveCredentials": { + "description": "Action button - delete Spotify credentials" + }, + "actionSaveCredentials": "Save Credentials", + "@actionSaveCredentials": { + "description": "Action button - save Spotify credentials" + }, + "selectionSelected": "{count} selected", + "@selectionSelected": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionAllSelected": "All tracks selected", + "@selectionAllSelected": { + "description": "Status - all items selected" + }, + "selectionTapToSelect": "Tap tracks to select", + "@selectionTapToSelect": { + "description": "Hint - how to select items" + }, + "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", + "@selectionDeleteTracks": { + "description": "Delete button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionSelectToDelete": "Select tracks to delete", + "@selectionSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "progressFetchingMetadata": "Fetching metadata... {current}/{total}", + "@progressFetchingMetadata": { + "description": "Progress indicator - loading track info", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "progressReadingCsv": "Reading CSV...", + "@progressReadingCsv": { + "description": "Progress indicator - parsing CSV file" + }, + "searchSongs": "Songs", + "@searchSongs": { + "description": "Search result category - songs" + }, + "searchArtists": "Artists", + "@searchArtists": { + "description": "Search result category - artists" + }, + "searchAlbums": "Albums", + "@searchAlbums": { + "description": "Search result category - albums" + }, + "searchPlaylists": "Playlists", + "@searchPlaylists": { + "description": "Search result category - playlists" + }, + "tooltipPlay": "Play", + "@tooltipPlay": { + "description": "Tooltip - play button" + }, + "tooltipCancel": "Cancel", + "@tooltipCancel": { + "description": "Tooltip - cancel button" + }, + "tooltipStop": "Stop", + "@tooltipStop": { + "description": "Tooltip - stop button" + }, + "tooltipRetry": "Retry", + "@tooltipRetry": { + "description": "Tooltip - retry button" + }, + "tooltipRemove": "Remove", + "@tooltipRemove": { + "description": "Tooltip - remove button" + }, + "tooltipClear": "Clear", + "@tooltipClear": { + "description": "Tooltip - clear button" + }, + "tooltipPaste": "Paste", + "@tooltipPaste": { + "description": "Tooltip - paste button" + }, + "filenameFormat": "Filename Format", + "@filenameFormat": { + "description": "Setting title - filename pattern" + }, + "filenameFormatPreview": "Preview: {preview}", + "@filenameFormatPreview": { + "description": "Preview of filename pattern", + "placeholders": { + "preview": { + "type": "String" + } + } + }, + "filenameAvailablePlaceholders": "Available placeholders:", + "@filenameAvailablePlaceholders": { + "description": "Label for placeholder list" + }, + "filenameHint": "{artist} - {title}", + "@filenameHint": { + "description": "Default filename format hint" + }, + "folderOrganization": "Folder Organization", + "@folderOrganization": { + "description": "Setting title - folder structure" + }, + "folderOrganizationNone": "No organization", + "@folderOrganizationNone": { + "description": "Folder option - flat structure" + }, + "folderOrganizationByArtist": "By Artist", + "@folderOrganizationByArtist": { + "description": "Folder option - artist folders" + }, + "folderOrganizationByAlbum": "By Album", + "@folderOrganizationByAlbum": { + "description": "Folder option - album folders" + }, + "folderOrganizationByArtistAlbum": "Artist/Album", + "@folderOrganizationByArtistAlbum": { + "description": "Folder option - nested folders" + }, + "folderOrganizationDescription": "Organize downloaded files into folders", + "@folderOrganizationDescription": { + "description": "Folder organization sheet description" + }, + "folderOrganizationNoneSubtitle": "All files in download folder", + "@folderOrganizationNoneSubtitle": { + "description": "Subtitle for no organization option" + }, + "folderOrganizationByArtistSubtitle": "Separate folder for each artist", + "@folderOrganizationByArtistSubtitle": { + "description": "Subtitle for artist folder option" + }, + "folderOrganizationByAlbumSubtitle": "Separate folder for each album", + "@folderOrganizationByAlbumSubtitle": { + "description": "Subtitle for album folder option" + }, + "folderOrganizationByArtistAlbumSubtitle": "Nested folders for artist and album", + "@folderOrganizationByArtistAlbumSubtitle": { + "description": "Subtitle for nested folder option" + }, + "updateAvailable": "Update Available", + "@updateAvailable": { + "description": "Update dialog title" + }, + "updateNewVersion": "Version {version} is available", + "@updateNewVersion": { + "description": "Update available message", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "updateDownload": "Download", + "@updateDownload": { + "description": "Update button - download update" + }, + "updateLater": "Later", + "@updateLater": { + "description": "Update button - dismiss" + }, + "updateChangelog": "Changelog", + "@updateChangelog": { + "description": "Link to changelog" + }, + "updateStartingDownload": "Starting download...", + "@updateStartingDownload": { + "description": "Update status - initializing" + }, + "updateDownloadFailed": "Download failed", + "@updateDownloadFailed": { + "description": "Update error title" + }, + "updateFailedMessage": "Failed to download update", + "@updateFailedMessage": { + "description": "Update error message" + }, + "updateNewVersionReady": "A new version is ready", + "@updateNewVersionReady": { + "description": "Update subtitle" + }, + "updateCurrent": "Current", + "@updateCurrent": { + "description": "Label for current version" + }, + "updateNew": "New", + "@updateNew": { + "description": "Label for new version" + }, + "updateDownloading": "Downloading...", + "@updateDownloading": { + "description": "Update status - downloading" + }, + "updateWhatsNew": "What's New", + "@updateWhatsNew": { + "description": "Changelog section title" + }, + "updateDownloadInstall": "Download & Install", + "@updateDownloadInstall": { + "description": "Update button - download and install" + }, + "updateDontRemind": "Don't remind", + "@updateDontRemind": { + "description": "Update button - skip this version" + }, + "providerPriority": "Provider Priority", + "@providerPriority": { + "description": "Setting title - download provider order" + }, + "providerPrioritySubtitle": "Drag to reorder download providers", + "@providerPrioritySubtitle": { + "description": "Subtitle for provider priority" + }, + "providerPriorityTitle": "Provider Priority", + "@providerPriorityTitle": { + "description": "Provider priority page title" + }, + "providerPriorityDescription": "Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.", + "@providerPriorityDescription": { + "description": "Provider priority page description" + }, + "providerPriorityInfo": "If a track is not available on the first provider, the app will automatically try the next one.", + "@providerPriorityInfo": { + "description": "Info tip about fallback behavior" + }, + "providerBuiltIn": "Built-in", + "@providerBuiltIn": { + "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + }, + "providerExtension": "Extension", + "@providerExtension": { + "description": "Label for extension-provided providers" + }, + "metadataProviderPriority": "Metadata Provider Priority", + "@metadataProviderPriority": { + "description": "Setting title - metadata provider order" + }, + "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", + "@metadataProviderPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "metadataProviderPriorityTitle": "Metadata Priority", + "@metadataProviderPriorityTitle": { + "description": "Metadata priority page title" + }, + "metadataProviderPriorityDescription": "Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.", + "@metadataProviderPriorityDescription": { + "description": "Metadata priority page description" + }, + "metadataProviderPriorityInfo": "Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.", + "@metadataProviderPriorityInfo": { + "description": "Info tip about rate limits" + }, + "metadataNoRateLimits": "No rate limits", + "@metadataNoRateLimits": { + "description": "Deezer provider description" + }, + "metadataMayRateLimit": "May rate limit", + "@metadataMayRateLimit": { + "description": "Spotify provider description" + }, + "logTitle": "Logs", + "@logTitle": { + "description": "Logs screen title" + }, + "logCopy": "Copy Logs", + "@logCopy": { + "description": "Action - copy logs to clipboard" + }, + "logClear": "Clear Logs", + "@logClear": { + "description": "Action - delete all logs" + }, + "logShare": "Share Logs", + "@logShare": { + "description": "Action - share logs file" + }, + "logEmpty": "No logs yet", + "@logEmpty": { + "description": "Empty state title" + }, + "logCopied": "Logs copied to clipboard", + "@logCopied": { + "description": "Snackbar - logs copied" + }, + "logSearchHint": "Search logs...", + "@logSearchHint": { + "description": "Log search placeholder" + }, + "logFilterLevel": "Level", + "@logFilterLevel": { + "description": "Filter by log level" + }, + "logFilterSection": "Filter", + "@logFilterSection": { + "description": "Filter section title" + }, + "logShareLogs": "Share logs", + "@logShareLogs": { + "description": "Share button tooltip" + }, + "logClearLogs": "Clear logs", + "@logClearLogs": { + "description": "Clear button tooltip" + }, + "logClearLogsTitle": "Clear Logs", + "@logClearLogsTitle": { + "description": "Clear logs dialog title" + }, + "logClearLogsMessage": "Are you sure you want to clear all logs?", + "@logClearLogsMessage": { + "description": "Clear logs confirmation message" + }, + "logIspBlocking": "ISP BLOCKING DETECTED", + "@logIspBlocking": { + "description": "Error category - ISP blocking" + }, + "logRateLimited": "RATE LIMITED", + "@logRateLimited": { + "description": "Error category - rate limiting" + }, + "logNetworkError": "NETWORK ERROR", + "@logNetworkError": { + "description": "Error category - network issues" + }, + "logTrackNotFound": "TRACK NOT FOUND", + "@logTrackNotFound": { + "description": "Error category - missing tracks" + }, + "logFilterBySeverity": "Filter logs by severity", + "@logFilterBySeverity": { + "description": "Filter dialog title" + }, + "logNoLogsYet": "No logs yet", + "@logNoLogsYet": { + "description": "Empty state title" + }, + "logNoLogsYetSubtitle": "Logs will appear here as you use the app", + "@logNoLogsYetSubtitle": { + "description": "Empty state subtitle" + }, + "logIssueSummary": "Issue Summary", + "@logIssueSummary": { + "description": "Section header for error summary" + }, + "logIspBlockingDescription": "Your ISP may be blocking access to download services", + "@logIspBlockingDescription": { + "description": "ISP blocking explanation" + }, + "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", + "@logIspBlockingSuggestion": { + "description": "ISP blocking fix suggestion" + }, + "logRateLimitedDescription": "Too many requests to the service", + "@logRateLimitedDescription": { + "description": "Rate limit explanation" + }, + "logRateLimitedSuggestion": "Wait a few minutes before trying again", + "@logRateLimitedSuggestion": { + "description": "Rate limit fix suggestion" + }, + "logNetworkErrorDescription": "Connection issues detected", + "@logNetworkErrorDescription": { + "description": "Network error explanation" + }, + "logNetworkErrorSuggestion": "Check your internet connection", + "@logNetworkErrorSuggestion": { + "description": "Network error fix suggestion" + }, + "logTrackNotFoundDescription": "Some tracks could not be found on download services", + "@logTrackNotFoundDescription": { + "description": "Track not found explanation" + }, + "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", + "@logTrackNotFoundSuggestion": { + "description": "Track not found explanation" + }, + "logTotalErrors": "Total errors: {count}", + "@logTotalErrors": { + "description": "Error count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logAffected": "Affected: {domains}", + "@logAffected": { + "description": "Affected domains display", + "placeholders": { + "domains": { + "type": "String" + } + } + }, + "logEntriesFiltered": "Entries ({count} filtered)", + "@logEntriesFiltered": { + "description": "Log count with filter active", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logEntries": "Entries ({count})", + "@logEntries": { + "description": "Total log count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "credentialsTitle": "Spotify Credentials", + "@credentialsTitle": { + "description": "Credentials dialog title" + }, + "credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.", + "@credentialsDescription": { + "description": "Credentials dialog explanation" + }, + "credentialsClientId": "Client ID", + "@credentialsClientId": { + "description": "Client ID field label - DO NOT TRANSLATE" + }, + "credentialsClientIdHint": "Paste Client ID", + "@credentialsClientIdHint": { + "description": "Client ID placeholder" + }, + "credentialsClientSecret": "Client Secret", + "@credentialsClientSecret": { + "description": "Client Secret field label - DO NOT TRANSLATE" + }, + "credentialsClientSecretHint": "Paste Client Secret", + "@credentialsClientSecretHint": { + "description": "Client Secret placeholder" + }, + "channelStable": "Stable", + "@channelStable": { + "description": "Update channel - stable releases" + }, + "channelPreview": "Preview", + "@channelPreview": { + "description": "Update channel - beta/preview releases" + }, + "sectionSearchSource": "Search Source", + "@sectionSearchSource": { + "description": "Settings section header" + }, + "sectionDownload": "Download", + "@sectionDownload": { + "description": "Settings section header" + }, + "sectionPerformance": "Performance", + "@sectionPerformance": { + "description": "Settings section header" + }, + "sectionApp": "App", + "@sectionApp": { + "description": "Settings section header" + }, + "sectionData": "Data", + "@sectionData": { + "description": "Settings section header" + }, + "sectionDebug": "Debug", + "@sectionDebug": { + "description": "Settings section header" + }, + "sectionService": "Service", + "@sectionService": { + "description": "Settings section header" + }, + "sectionAudioQuality": "Audio Quality", + "@sectionAudioQuality": { + "description": "Settings section header" + }, + "sectionFileSettings": "File Settings", + "@sectionFileSettings": { + "description": "Settings section header" + }, + "sectionColor": "Color", + "@sectionColor": { + "description": "Settings section header" + }, + "sectionTheme": "Theme", + "@sectionTheme": { + "description": "Settings section header" + }, + "sectionLayout": "Layout", + "@sectionLayout": { + "description": "Settings section header" + }, + "sectionLanguage": "Language", + "@sectionLanguage": { + "description": "Settings section header for language" + }, + "appearanceLanguage": "App Language", + "@appearanceLanguage": { + "description": "Language setting title" + }, + "appearanceLanguageSubtitle": "Choose your preferred language", + "@appearanceLanguageSubtitle": { + "description": "Language setting subtitle" + }, + "settingsAppearanceSubtitle": "Theme, colors, display", + "@settingsAppearanceSubtitle": { + "description": "Appearance settings description" + }, + "settingsDownloadSubtitle": "Service, quality, filename format", + "@settingsDownloadSubtitle": { + "description": "Download settings description" + }, + "settingsOptionsSubtitle": "Fallback, lyrics, cover art, updates", + "@settingsOptionsSubtitle": { + "description": "Options settings description" + }, + "settingsExtensionsSubtitle": "Manage download providers", + "@settingsExtensionsSubtitle": { + "description": "Extensions settings description" + }, + "settingsLogsSubtitle": "View app logs for debugging", + "@settingsLogsSubtitle": { + "description": "Logs settings description" + }, + "loadingSharedLink": "Loading shared link...", + "@loadingSharedLink": { + "description": "Status when opening shared URL" + }, + "pressBackAgainToExit": "Press back again to exit", + "@pressBackAgainToExit": { + "description": "Exit confirmation message" + }, + "tracksHeader": "Tracks", + "@tracksHeader": { + "description": "Section header for track list" + }, + "downloadAllCount": "Download All ({count})", + "@downloadAllCount": { + "description": "Download all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "@tracksCount": { + "description": "Track count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trackCopyFilePath": "Copy file path", + "@trackCopyFilePath": { + "description": "Action - copy file path" + }, + "trackRemoveFromDevice": "Remove from device", + "@trackRemoveFromDevice": { + "description": "Action - delete downloaded file" + }, + "trackLoadLyrics": "Load Lyrics", + "@trackLoadLyrics": { + "description": "Action - fetch lyrics" + }, + "trackMetadata": "Metadata", + "@trackMetadata": { + "description": "Tab title - track metadata" + }, + "trackFileInfo": "File Info", + "@trackFileInfo": { + "description": "Tab title - file information" + }, + "trackLyrics": "Lyrics", + "@trackLyrics": { + "description": "Tab title - lyrics" + }, + "trackFileNotFound": "File not found", + "@trackFileNotFound": { + "description": "Error - file doesn't exist" + }, + "trackOpenInDeezer": "Open in Deezer", + "@trackOpenInDeezer": { + "description": "Action - open track in Deezer app" + }, + "trackOpenInSpotify": "Open in Spotify", + "@trackOpenInSpotify": { + "description": "Action - open track in Spotify app" + }, + "trackTrackName": "Track name", + "@trackTrackName": { + "description": "Metadata label - track title" + }, + "trackArtist": "Artist", + "@trackArtist": { + "description": "Metadata label - artist name" + }, + "trackAlbumArtist": "Album artist", + "@trackAlbumArtist": { + "description": "Metadata label - album artist" + }, + "trackAlbum": "Album", + "@trackAlbum": { + "description": "Metadata label - album name" + }, + "trackTrackNumber": "Track number", + "@trackTrackNumber": { + "description": "Metadata label - track number" + }, + "trackDiscNumber": "Disc number", + "@trackDiscNumber": { + "description": "Metadata label - disc number" + }, + "trackDuration": "Duration", + "@trackDuration": { + "description": "Metadata label - track length" + }, + "trackAudioQuality": "Audio quality", + "@trackAudioQuality": { + "description": "Metadata label - audio quality" + }, + "trackReleaseDate": "Release date", + "@trackReleaseDate": { + "description": "Metadata label - release date" + }, + "trackDownloaded": "Downloaded", + "@trackDownloaded": { + "description": "Metadata label - download date" + }, + "trackCopyLyrics": "Copy lyrics", + "@trackCopyLyrics": { + "description": "Action - copy lyrics to clipboard" + }, + "trackLyricsNotAvailable": "Lyrics not available for this track", + "@trackLyricsNotAvailable": { + "description": "Message when lyrics not found" + }, + "trackLyricsTimeout": "Request timed out. Try again later.", + "@trackLyricsTimeout": { + "description": "Message when lyrics request times out" + }, + "trackLyricsLoadFailed": "Failed to load lyrics", + "@trackLyricsLoadFailed": { + "description": "Message when lyrics loading fails" + }, + "trackCopiedToClipboard": "Copied to clipboard", + "@trackCopiedToClipboard": { + "description": "Snackbar - content copied" + }, + "trackDeleteConfirmTitle": "Remove from device?", + "@trackDeleteConfirmTitle": { + "description": "Delete confirmation title" + }, + "trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", + "@trackDeleteConfirmMessage": { + "description": "Delete confirmation message" + }, + "trackCannotOpen": "Cannot open: {message}", + "@trackCannotOpen": { + "description": "Error opening file", + "placeholders": { + "message": { + "type": "String" + } + } + }, + "dateToday": "Today", + "@dateToday": { + "description": "Relative date - today" + }, + "dateYesterday": "Yesterday", + "@dateYesterday": { + "description": "Relative date - yesterday" + }, + "dateDaysAgo": "{count} days ago", + "@dateDaysAgo": { + "description": "Relative date - days ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateWeeksAgo": "{count} weeks ago", + "@dateWeeksAgo": { + "description": "Relative date - weeks ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateMonthsAgo": "{count} months ago", + "@dateMonthsAgo": { + "description": "Relative date - months ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "concurrentSequential": "Sequential", + "@concurrentSequential": { + "description": "Download mode - one at a time" + }, + "concurrentParallel2": "2 Parallel", + "@concurrentParallel2": { + "description": "Download mode - 2 simultaneous" + }, + "concurrentParallel3": "3 Parallel", + "@concurrentParallel3": { + "description": "Download mode - 3 simultaneous" + }, + "tapToSeeError": "Tap to see error details", + "@tapToSeeError": { + "description": "Tooltip for failed download" + }, + "storeFilterAll": "All", + "@storeFilterAll": { + "description": "Store filter - all extensions" + }, + "storeFilterMetadata": "Metadata", + "@storeFilterMetadata": { + "description": "Store filter - metadata providers" + }, + "storeFilterDownload": "Download", + "@storeFilterDownload": { + "description": "Store filter - download providers" + }, + "storeFilterUtility": "Utility", + "@storeFilterUtility": { + "description": "Store filter - utility extensions" + }, + "storeFilterLyrics": "Lyrics", + "@storeFilterLyrics": { + "description": "Store filter - lyrics providers" + }, + "storeFilterIntegration": "Integration", + "@storeFilterIntegration": { + "description": "Store filter - integrations" + }, + "storeClearFilters": "Clear filters", + "@storeClearFilters": { + "description": "Button to clear all filters" + }, + "storeNoResults": "No extensions found", + "@storeNoResults": { + "description": "Empty state when no extensions match filters" + }, + "extensionProviderPriority": "Provider Priority", + "@extensionProviderPriority": { + "description": "Extension capability - provider priority" + }, + "extensionInstallButton": "Install Extension", + "@extensionInstallButton": { + "description": "Button to install extension" + }, + "extensionDefaultProvider": "Default (Deezer/Spotify)", + "@extensionDefaultProvider": { + "description": "Default search provider option" + }, + "extensionDefaultProviderSubtitle": "Use built-in search", + "@extensionDefaultProviderSubtitle": { + "description": "Subtitle for default provider" + }, + "extensionAuthor": "Author", + "@extensionAuthor": { + "description": "Extension detail - author" + }, + "extensionId": "ID", + "@extensionId": { + "description": "Extension detail - unique ID" + }, + "extensionError": "Error", + "@extensionError": { + "description": "Extension detail - error message" + }, + "extensionCapabilities": "Capabilities", + "@extensionCapabilities": { + "description": "Section header - extension features" + }, + "extensionMetadataProvider": "Metadata Provider", + "@extensionMetadataProvider": { + "description": "Capability - provides metadata" + }, + "extensionDownloadProvider": "Download Provider", + "@extensionDownloadProvider": { + "description": "Capability - provides downloads" + }, + "extensionLyricsProvider": "Lyrics Provider", + "@extensionLyricsProvider": { + "description": "Capability - provides lyrics" + }, + "extensionUrlHandler": "URL Handler", + "@extensionUrlHandler": { + "description": "Capability - handles URLs" + }, + "extensionQualityOptions": "Quality Options", + "@extensionQualityOptions": { + "description": "Capability - quality selection" + }, + "extensionPostProcessingHooks": "Post-Processing Hooks", + "@extensionPostProcessingHooks": { + "description": "Capability - post-processing" + }, + "extensionPermissions": "Permissions", + "@extensionPermissions": { + "description": "Section header - required permissions" + }, + "extensionSettings": "Settings", + "@extensionSettings": { + "description": "Section header - extension settings" + }, + "extensionRemoveButton": "Remove Extension", + "@extensionRemoveButton": { + "description": "Button to uninstall extension" + }, + "extensionUpdated": "Updated", + "@extensionUpdated": { + "description": "Extension detail - last update" + }, + "extensionMinAppVersion": "Min App Version", + "@extensionMinAppVersion": { + "description": "Extension detail - minimum app version" + }, + "extensionCustomTrackMatching": "Custom Track Matching", + "@extensionCustomTrackMatching": { + "description": "Capability - custom track matching algorithm" + }, + "extensionPostProcessing": "Post-Processing", + "@extensionPostProcessing": { + "description": "Capability - post-download processing" + }, + "extensionHooksAvailable": "{count} hook(s) available", + "@extensionHooksAvailable": { + "description": "Post-processing hooks count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionPatternsCount": "{count} pattern(s)", + "@extensionPatternsCount": { + "description": "URL patterns count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionStrategy": "Strategy: {strategy}", + "@extensionStrategy": { + "description": "Track matching strategy name", + "placeholders": { + "strategy": { + "type": "String" + } + } + }, + "extensionsProviderPrioritySection": "Provider Priority", + "@extensionsProviderPrioritySection": { + "description": "Section header - provider priority" + }, + "extensionsInstalledSection": "Installed Extensions", + "@extensionsInstalledSection": { + "description": "Section header - installed extensions" + }, + "extensionsNoExtensions": "No extensions installed", + "@extensionsNoExtensions": { + "description": "Empty state - no extensions" + }, + "extensionsNoExtensionsSubtitle": "Install .spotiflac-ext files to add new providers", + "@extensionsNoExtensionsSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsInstallButton": "Install Extension", + "@extensionsInstallButton": { + "description": "Button to install extension from file" + }, + "extensionsInfoTip": "Extensions can add new metadata and download providers. Only install extensions from trusted sources.", + "@extensionsInfoTip": { + "description": "Security warning about extensions" + }, + "extensionsInstalledSuccess": "Extension installed successfully", + "@extensionsInstalledSuccess": { + "description": "Success message after install" + }, + "extensionsDownloadPriority": "Download Priority", + "@extensionsDownloadPriority": { + "description": "Setting - download provider order" + }, + "extensionsDownloadPrioritySubtitle": "Set download service order", + "@extensionsDownloadPrioritySubtitle": { + "description": "Subtitle for download priority" + }, + "extensionsNoDownloadProvider": "No extensions with download provider", + "@extensionsNoDownloadProvider": { + "description": "Empty state - no download providers" + }, + "extensionsMetadataPriority": "Metadata Priority", + "@extensionsMetadataPriority": { + "description": "Setting - metadata provider order" + }, + "extensionsMetadataPrioritySubtitle": "Set search & metadata source order", + "@extensionsMetadataPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "extensionsNoMetadataProvider": "No extensions with metadata provider", + "@extensionsNoMetadataProvider": { + "description": "Empty state - no metadata providers" + }, + "extensionsSearchProvider": "Search Provider", + "@extensionsSearchProvider": { + "description": "Setting - search provider selection" + }, + "extensionsNoCustomSearch": "No extensions with custom search", + "@extensionsNoCustomSearch": { + "description": "Empty state - no search providers" + }, + "extensionsSearchProviderDescription": "Choose which service to use for searching tracks", + "@extensionsSearchProviderDescription": { + "description": "Search provider setting description" + }, + "extensionsCustomSearch": "Custom search", + "@extensionsCustomSearch": { + "description": "Label for custom search provider" + }, + "extensionsErrorLoading": "Error loading extension", + "@extensionsErrorLoading": { + "description": "Error message when extension fails to load" + }, + "qualityFlacLossless": "FLAC Lossless", + "@qualityFlacLossless": { + "description": "Quality option - CD quality FLAC" + }, + "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", + "@qualityFlacLosslessSubtitle": { + "description": "Technical spec for lossless" + }, + "qualityHiResFlac": "Hi-Res FLAC", + "@qualityHiResFlac": { + "description": "Quality option - high resolution FLAC" + }, + "qualityHiResFlacSubtitle": "24-bit / up to 96kHz", + "@qualityHiResFlacSubtitle": { + "description": "Technical spec for hi-res" + }, + "qualityHiResFlacMax": "Hi-Res FLAC Max", + "@qualityHiResFlacMax": { + "description": "Quality option - maximum resolution FLAC" + }, + "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", + "@qualityHiResFlacMaxSubtitle": { + "description": "Technical spec for hi-res max" + }, + "qualityNote": "Actual quality depends on track availability from the service", + "@qualityNote": { + "description": "Note about quality availability" + }, + "downloadAskBeforeDownload": "Ask Before Download", + "@downloadAskBeforeDownload": { + "description": "Setting - show quality picker" + }, + "downloadDirectory": "Download Directory", + "@downloadDirectory": { + "description": "Setting - download folder" + }, + "downloadSeparateSinglesFolder": "Separate Singles Folder", + "@downloadSeparateSinglesFolder": { + "description": "Setting - separate folder for singles" + }, + "downloadAlbumFolderStructure": "Album Folder Structure", + "@downloadAlbumFolderStructure": { + "description": "Setting - album folder organization" + }, + "downloadSaveFormat": "Save Format", + "@downloadSaveFormat": { + "description": "Setting - output file format" + }, + "downloadSelectService": "Select Service", + "@downloadSelectService": { + "description": "Dialog title - choose download service" + }, + "downloadSelectQuality": "Select Quality", + "@downloadSelectQuality": { + "description": "Dialog title - choose audio quality" + }, + "downloadFrom": "Download From", + "@downloadFrom": { + "description": "Label - download source" + }, + "downloadDefaultQualityLabel": "Default Quality", + "@downloadDefaultQualityLabel": { + "description": "Label - default quality setting" + }, + "downloadBestAvailable": "Best available", + "@downloadBestAvailable": { + "description": "Quality option - highest available" + }, + "folderNone": "None", + "@folderNone": { + "description": "Folder option - no organization" + }, + "folderNoneSubtitle": "Save all files directly to download folder", + "@folderNoneSubtitle": { + "description": "Subtitle for no folder organization" + }, + "folderArtist": "Artist", + "@folderArtist": { + "description": "Folder option - by artist" + }, + "folderArtistSubtitle": "Artist Name/filename", + "@folderArtistSubtitle": { + "description": "Folder structure example" + }, + "folderAlbum": "Album", + "@folderAlbum": { + "description": "Folder option - by album" + }, + "folderAlbumSubtitle": "Album Name/filename", + "@folderAlbumSubtitle": { + "description": "Folder structure example" + }, + "folderArtistAlbum": "Artist/Album", + "@folderArtistAlbum": { + "description": "Folder option - nested" + }, + "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", + "@folderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "serviceTidal": "Tidal", + "@serviceTidal": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceQobuz": "Qobuz", + "@serviceQobuz": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceAmazon": "Amazon", + "@serviceAmazon": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceDeezer": "Deezer", + "@serviceDeezer": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceSpotify": "Spotify", + "@serviceSpotify": { + "description": "Service name - DO NOT TRANSLATE" + }, + "appearanceAmoledDark": "AMOLED Dark", + "@appearanceAmoledDark": { + "description": "Theme option - pure black" + }, + "appearanceAmoledDarkSubtitle": "Pure black background", + "@appearanceAmoledDarkSubtitle": { + "description": "Subtitle for AMOLED dark" + }, + "appearanceChooseAccentColor": "Choose Accent Color", + "@appearanceChooseAccentColor": { + "description": "Color picker dialog title" + }, + "appearanceChooseTheme": "Theme Mode", + "@appearanceChooseTheme": { + "description": "Theme picker dialog title" + }, + "queueTitle": "Download Queue", + "@queueTitle": { + "description": "Queue screen title" + }, + "queueClearAll": "Clear All", + "@queueClearAll": { + "description": "Button - clear all queue items" + }, + "queueClearAllMessage": "Are you sure you want to clear all downloads?", + "@queueClearAllMessage": { + "description": "Clear queue confirmation" + }, + "queueEmpty": "No downloads in queue", + "@queueEmpty": { + "description": "Empty queue state title" + }, + "queueEmptySubtitle": "Add tracks from the home screen", + "@queueEmptySubtitle": { + "description": "Empty queue state subtitle" + }, + "queueClearCompleted": "Clear completed", + "@queueClearCompleted": { + "description": "Button - clear finished downloads" + }, + "queueDownloadFailed": "Download Failed", + "@queueDownloadFailed": { + "description": "Error dialog title" + }, + "queueTrackLabel": "Track:", + "@queueTrackLabel": { + "description": "Label in error dialog" + }, + "queueArtistLabel": "Artist:", + "@queueArtistLabel": { + "description": "Label in error dialog" + }, + "queueErrorLabel": "Error:", + "@queueErrorLabel": { + "description": "Label in error dialog" + }, + "queueUnknownError": "Unknown error", + "@queueUnknownError": { + "description": "Fallback error message" + }, + "albumFolderArtistAlbum": "Artist / Album", + "@albumFolderArtistAlbum": { + "description": "Album folder option" + }, + "albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/", + "@albumFolderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderArtistYearAlbum": "Artist / [Year] Album", + "@albumFolderArtistYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/", + "@albumFolderArtistYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderAlbumOnly": "Album Only", + "@albumFolderAlbumOnly": { + "description": "Album folder option" + }, + "albumFolderAlbumOnlySubtitle": "Albums/Album Name/", + "@albumFolderAlbumOnlySubtitle": { + "description": "Folder structure example" + }, + "albumFolderYearAlbum": "[Year] Album", + "@albumFolderYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", + "@albumFolderYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "downloadedAlbumDeleteSelected": "Delete Selected", + "@downloadedAlbumDeleteSelected": { + "description": "Button - delete selected tracks" + }, + "downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.", + "@downloadedAlbumDeleteMessage": { + "description": "Delete confirmation with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumTracksHeader": "Tracks", + "@downloadedAlbumTracksHeader": { + "description": "Section header for tracks" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectedCount": "{count} selected", + "@downloadedAlbumSelectedCount": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumAllSelected": "All tracks selected", + "@downloadedAlbumAllSelected": { + "description": "Status - all items selected" + }, + "downloadedAlbumTapToSelect": "Tap tracks to select", + "@downloadedAlbumTapToSelect": { + "description": "Selection hint" + }, + "downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}", + "@downloadedAlbumDeleteCount": { + "description": "Delete button text with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectToDelete": "Select tracks to delete", + "@downloadedAlbumSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "utilityFunctions": "Utility Functions", + "@utilityFunctions": { + "description": "Extension capability - utility functions" + }, + "recentTypeArtist": "Artist", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Song", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": { + "type": "String", + "description": "Error message" + } + } + } +} \ No newline at end of file From 720525b67b6a8ea2a74e75d3215c56d29964d1ee Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:22 +0700 Subject: [PATCH 04/48] New translations app_en.arb (German) --- lib/l10n/arb/app_de.arb | 70 +++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index c5d32df4..8a57812d 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1,6 +1,6 @@ { "@@locale": "de", - "@@last_modified": "2026-01-17", + "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { "description": "App name - DO NOT TRANSLATE" @@ -642,6 +642,20 @@ } } }, + "artistPopular": "Popular", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} monthly listeners", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, "trackMetadataTitle": "Track Info", "@trackMetadataTitle": { "description": "Track metadata screen title" @@ -1851,27 +1865,15 @@ }, "sectionLanguage": "Language", "@sectionLanguage": { - "description": "Settings section header for language selection" + "description": "Settings section header for language" }, "appearanceLanguage": "App Language", "@appearanceLanguage": { - "description": "Setting title for language selection" + "description": "Language setting title" }, "appearanceLanguageSubtitle": "Choose your preferred language", "@appearanceLanguageSubtitle": { - "description": "Subtitle for language setting" - }, - "languageSystem": "System Default", - "@languageSystem": { - "description": "Use device system language" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" + "description": "Language setting subtitle" }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { @@ -2573,5 +2575,41 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" + }, + "recentTypeArtist": "Artist", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Song", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": { + "type": "String", + "description": "Error message" + } + } } } \ No newline at end of file From dddc8c3d94005649d8f11193dca6c691a3d8bf81 Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:24 +0700 Subject: [PATCH 05/48] New translations app_en.arb (Korean) --- lib/l10n/arb/app_ko.arb | 68 ++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index cc0ccc73..92bee94b 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -642,6 +642,20 @@ } } }, + "artistPopular": "Popular", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} monthly listeners", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, "trackMetadataTitle": "Track Info", "@trackMetadataTitle": { "description": "Track metadata screen title" @@ -1851,27 +1865,15 @@ }, "sectionLanguage": "Language", "@sectionLanguage": { - "description": "Settings section header for language selection" + "description": "Settings section header for language" }, "appearanceLanguage": "App Language", "@appearanceLanguage": { - "description": "Setting title for language selection" + "description": "Language setting title" }, "appearanceLanguageSubtitle": "Choose your preferred language", "@appearanceLanguageSubtitle": { - "description": "Subtitle for language setting" - }, - "languageSystem": "System Default", - "@languageSystem": { - "description": "Use device system language" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" + "description": "Language setting subtitle" }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { @@ -2573,5 +2575,41 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" + }, + "recentTypeArtist": "Artist", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Song", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": { + "type": "String", + "description": "Error message" + } + } } } \ No newline at end of file From ff882a58d7c04359ab201515a6cf89cfa6f86f30 Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:25 +0700 Subject: [PATCH 06/48] New translations app_en.arb (Dutch) --- lib/l10n/arb/app_nl.arb | 68 ++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 62e26100..c762fd9f 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -642,6 +642,20 @@ } } }, + "artistPopular": "Popular", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} monthly listeners", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, "trackMetadataTitle": "Track Info", "@trackMetadataTitle": { "description": "Track metadata screen title" @@ -1851,27 +1865,15 @@ }, "sectionLanguage": "Language", "@sectionLanguage": { - "description": "Settings section header for language selection" + "description": "Settings section header for language" }, "appearanceLanguage": "App Language", "@appearanceLanguage": { - "description": "Setting title for language selection" + "description": "Language setting title" }, "appearanceLanguageSubtitle": "Choose your preferred language", "@appearanceLanguageSubtitle": { - "description": "Subtitle for language setting" - }, - "languageSystem": "System Default", - "@languageSystem": { - "description": "Use device system language" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" + "description": "Language setting subtitle" }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { @@ -2573,5 +2575,41 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" + }, + "recentTypeArtist": "Artist", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Song", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": { + "type": "String", + "description": "Error message" + } + } } } \ No newline at end of file From 1069bdd0d89b8a3d1b83e438ea22ef80b70171fd Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:25 +0700 Subject: [PATCH 07/48] New translations app_en.arb (Portuguese) --- lib/l10n/arb/app_pt-PT.arb | 2615 ++++++++++++++++++++++++++++++++++++ 1 file changed, 2615 insertions(+) create mode 100644 lib/l10n/arb/app_pt-PT.arb diff --git a/lib/l10n/arb/app_pt-PT.arb b/lib/l10n/arb/app_pt-PT.arb new file mode 100644 index 00000000..7f007ecc --- /dev/null +++ b/lib/l10n/arb/app_pt-PT.arb @@ -0,0 +1,2615 @@ +{ + "@@locale": "pt-PT", + "@@last_modified": "2026-01-16", + "appName": "SpotiFLAC", + "@appName": { + "description": "App name - DO NOT TRANSLATE" + }, + "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "@appDescription": { + "description": "App description shown in about page" + }, + "navHome": "Home", + "@navHome": { + "description": "Bottom navigation - Home tab" + }, + "navHistory": "History", + "@navHistory": { + "description": "Bottom navigation - History tab" + }, + "navSettings": "Settings", + "@navSettings": { + "description": "Bottom navigation - Settings tab" + }, + "navStore": "Store", + "@navStore": { + "description": "Bottom navigation - Extension store tab" + }, + "homeTitle": "Home", + "@homeTitle": { + "description": "Home screen title" + }, + "homeSearchHint": "Paste Spotify URL or search...", + "@homeSearchHint": { + "description": "Placeholder text in search box" + }, + "homeSearchHintExtension": "Search with {extensionName}...", + "@homeSearchHintExtension": { + "description": "Placeholder when extension search is active", + "placeholders": { + "extensionName": { + "type": "String", + "description": "Name of the active extension" + } + } + }, + "homeSubtitle": "Paste a Spotify link or search by name", + "@homeSubtitle": { + "description": "Subtitle shown below search box" + }, + "homeSupports": "Supports: Track, Album, Playlist, Artist URLs", + "@homeSupports": { + "description": "Info text about supported URL types" + }, + "homeRecent": "Recent", + "@homeRecent": { + "description": "Section header for recent searches" + }, + "historyTitle": "History", + "@historyTitle": { + "description": "History screen title" + }, + "historyDownloading": "Downloading ({count})", + "@historyDownloading": { + "description": "Tab showing active downloads count", + "placeholders": { + "count": { + "type": "int", + "description": "Number of active downloads" + } + } + }, + "historyDownloaded": "Downloaded", + "@historyDownloaded": { + "description": "Tab showing completed downloads" + }, + "historyFilterAll": "All", + "@historyFilterAll": { + "description": "Filter chip - show all items" + }, + "historyFilterAlbums": "Albums", + "@historyFilterAlbums": { + "description": "Filter chip - show albums only" + }, + "historyFilterSingles": "Singles", + "@historyFilterSingles": { + "description": "Filter chip - show singles only" + }, + "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "@historyTracksCount": { + "description": "Track count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", + "@historyAlbumsCount": { + "description": "Album count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "historyNoDownloads": "No download history", + "@historyNoDownloads": { + "description": "Empty state title" + }, + "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", + "@historyNoDownloadsSubtitle": { + "description": "Empty state subtitle" + }, + "historyNoAlbums": "No album downloads", + "@historyNoAlbums": { + "description": "Empty state when filtering albums" + }, + "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", + "@historyNoAlbumsSubtitle": { + "description": "Empty state subtitle for albums filter" + }, + "historyNoSingles": "No single downloads", + "@historyNoSingles": { + "description": "Empty state when filtering singles" + }, + "historyNoSinglesSubtitle": "Single track downloads will appear here", + "@historyNoSinglesSubtitle": { + "description": "Empty state subtitle for singles filter" + }, + "settingsTitle": "Settings", + "@settingsTitle": { + "description": "Settings screen title" + }, + "settingsDownload": "Download", + "@settingsDownload": { + "description": "Settings section - download options" + }, + "settingsAppearance": "Appearance", + "@settingsAppearance": { + "description": "Settings section - visual customization" + }, + "settingsOptions": "Options", + "@settingsOptions": { + "description": "Settings section - app options" + }, + "settingsExtensions": "Extensions", + "@settingsExtensions": { + "description": "Settings section - extension management" + }, + "settingsAbout": "About", + "@settingsAbout": { + "description": "Settings section - app info" + }, + "downloadTitle": "Download", + "@downloadTitle": { + "description": "Download settings page title" + }, + "downloadLocation": "Download Location", + "@downloadLocation": { + "description": "Setting for download folder" + }, + "downloadLocationSubtitle": "Choose where to save files", + "@downloadLocationSubtitle": { + "description": "Subtitle for download location" + }, + "downloadLocationDefault": "Default location", + "@downloadLocationDefault": { + "description": "Shown when using default folder" + }, + "downloadDefaultService": "Default Service", + "@downloadDefaultService": { + "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" + }, + "downloadDefaultServiceSubtitle": "Service used for downloads", + "@downloadDefaultServiceSubtitle": { + "description": "Subtitle for default service" + }, + "downloadDefaultQuality": "Default Quality", + "@downloadDefaultQuality": { + "description": "Setting for audio quality" + }, + "downloadAskQuality": "Ask Quality Before Download", + "@downloadAskQuality": { + "description": "Toggle to show quality picker" + }, + "downloadAskQualitySubtitle": "Show quality picker for each download", + "@downloadAskQualitySubtitle": { + "description": "Subtitle for ask quality toggle" + }, + "downloadFilenameFormat": "Filename Format", + "@downloadFilenameFormat": { + "description": "Setting for output filename pattern" + }, + "downloadFolderOrganization": "Folder Organization", + "@downloadFolderOrganization": { + "description": "Setting for folder structure" + }, + "downloadSeparateSingles": "Separate Singles", + "@downloadSeparateSingles": { + "description": "Toggle to separate single tracks" + }, + "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", + "@downloadSeparateSinglesSubtitle": { + "description": "Subtitle for separate singles toggle" + }, + "qualityBest": "Best Available", + "@qualityBest": { + "description": "Audio quality option - highest available" + }, + "qualityFlac": "FLAC", + "@qualityFlac": { + "description": "Audio quality option - FLAC lossless" + }, + "quality320": "320 kbps", + "@quality320": { + "description": "Audio quality option - 320kbps MP3" + }, + "quality128": "128 kbps", + "@quality128": { + "description": "Audio quality option - 128kbps MP3" + }, + "appearanceTitle": "Appearance", + "@appearanceTitle": { + "description": "Appearance settings page title" + }, + "appearanceTheme": "Theme", + "@appearanceTheme": { + "description": "Theme mode setting" + }, + "appearanceThemeSystem": "System", + "@appearanceThemeSystem": { + "description": "Follow system theme" + }, + "appearanceThemeLight": "Light", + "@appearanceThemeLight": { + "description": "Light theme" + }, + "appearanceThemeDark": "Dark", + "@appearanceThemeDark": { + "description": "Dark theme" + }, + "appearanceDynamicColor": "Dynamic Color", + "@appearanceDynamicColor": { + "description": "Material You dynamic colors" + }, + "appearanceDynamicColorSubtitle": "Use colors from your wallpaper", + "@appearanceDynamicColorSubtitle": { + "description": "Subtitle for dynamic color" + }, + "appearanceAccentColor": "Accent Color", + "@appearanceAccentColor": { + "description": "Custom accent color picker" + }, + "appearanceHistoryView": "History View", + "@appearanceHistoryView": { + "description": "Layout style for history" + }, + "appearanceHistoryViewList": "List", + "@appearanceHistoryViewList": { + "description": "List layout option" + }, + "appearanceHistoryViewGrid": "Grid", + "@appearanceHistoryViewGrid": { + "description": "Grid layout option" + }, + "optionsTitle": "Options", + "@optionsTitle": { + "description": "Options settings page title" + }, + "optionsSearchSource": "Search Source", + "@optionsSearchSource": { + "description": "Section for search provider settings" + }, + "optionsPrimaryProvider": "Primary Provider", + "@optionsPrimaryProvider": { + "description": "Main search provider setting" + }, + "optionsPrimaryProviderSubtitle": "Service used when searching by track name.", + "@optionsPrimaryProviderSubtitle": { + "description": "Subtitle for primary provider" + }, + "optionsUsingExtension": "Using extension: {extensionName}", + "@optionsUsingExtension": { + "description": "Shows active extension name", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", + "@optionsSwitchBack": { + "description": "Hint to switch back to built-in providers" + }, + "optionsAutoFallback": "Auto Fallback", + "@optionsAutoFallback": { + "description": "Auto-retry with other services" + }, + "optionsAutoFallbackSubtitle": "Try other services if download fails", + "@optionsAutoFallbackSubtitle": { + "description": "Subtitle for auto fallback" + }, + "optionsUseExtensionProviders": "Use Extension Providers", + "@optionsUseExtensionProviders": { + "description": "Enable extension download providers" + }, + "optionsUseExtensionProvidersOn": "Extensions will be tried first", + "@optionsUseExtensionProvidersOn": { + "description": "Status when extension providers enabled" + }, + "optionsUseExtensionProvidersOff": "Using built-in providers only", + "@optionsUseExtensionProvidersOff": { + "description": "Status when extension providers disabled" + }, + "optionsEmbedLyrics": "Embed Lyrics", + "@optionsEmbedLyrics": { + "description": "Embed lyrics in audio files" + }, + "optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files", + "@optionsEmbedLyricsSubtitle": { + "description": "Subtitle for embed lyrics" + }, + "optionsMaxQualityCover": "Max Quality Cover", + "@optionsMaxQualityCover": { + "description": "Download highest quality album art" + }, + "optionsMaxQualityCoverSubtitle": "Download highest resolution cover art", + "@optionsMaxQualityCoverSubtitle": { + "description": "Subtitle for max quality cover" + }, + "optionsConcurrentDownloads": "Concurrent Downloads", + "@optionsConcurrentDownloads": { + "description": "Number of parallel downloads" + }, + "optionsConcurrentSequential": "Sequential (1 at a time)", + "@optionsConcurrentSequential": { + "description": "Download one at a time" + }, + "optionsConcurrentParallel": "{count} parallel downloads", + "@optionsConcurrentParallel": { + "description": "Multiple parallel downloads", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "optionsConcurrentWarning": "Parallel downloads may trigger rate limiting", + "@optionsConcurrentWarning": { + "description": "Warning about rate limits" + }, + "optionsExtensionStore": "Extension Store", + "@optionsExtensionStore": { + "description": "Show/hide store tab" + }, + "optionsExtensionStoreSubtitle": "Show Store tab in navigation", + "@optionsExtensionStoreSubtitle": { + "description": "Subtitle for extension store toggle" + }, + "optionsCheckUpdates": "Check for Updates", + "@optionsCheckUpdates": { + "description": "Auto update check toggle" + }, + "optionsCheckUpdatesSubtitle": "Notify when new version is available", + "@optionsCheckUpdatesSubtitle": { + "description": "Subtitle for update check" + }, + "optionsUpdateChannel": "Update Channel", + "@optionsUpdateChannel": { + "description": "Stable vs preview releases" + }, + "optionsUpdateChannelStable": "Stable releases only", + "@optionsUpdateChannelStable": { + "description": "Only stable updates" + }, + "optionsUpdateChannelPreview": "Get preview releases", + "@optionsUpdateChannelPreview": { + "description": "Include beta/preview updates" + }, + "optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features", + "@optionsUpdateChannelWarning": { + "description": "Warning about preview channel" + }, + "optionsClearHistory": "Clear Download History", + "@optionsClearHistory": { + "description": "Delete all download history" + }, + "optionsClearHistorySubtitle": "Remove all downloaded tracks from history", + "@optionsClearHistorySubtitle": { + "description": "Subtitle for clear history" + }, + "optionsDetailedLogging": "Detailed Logging", + "@optionsDetailedLogging": { + "description": "Enable verbose logs for debugging" + }, + "optionsDetailedLoggingOn": "Detailed logs are being recorded", + "@optionsDetailedLoggingOn": { + "description": "Status when logging enabled" + }, + "optionsDetailedLoggingOff": "Enable for bug reports", + "@optionsDetailedLoggingOff": { + "description": "Status when logging disabled" + }, + "optionsSpotifyCredentials": "Spotify Credentials", + "@optionsSpotifyCredentials": { + "description": "Spotify API credentials setting" + }, + "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", + "@optionsSpotifyCredentialsConfigured": { + "description": "Shows configured client ID preview", + "placeholders": { + "clientId": { + "type": "String" + } + } + }, + "optionsSpotifyCredentialsRequired": "Required - tap to configure", + "@optionsSpotifyCredentialsRequired": { + "description": "Prompt to set up credentials" + }, + "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", + "@optionsSpotifyWarning": { + "description": "Info about Spotify API requirement" + }, + "extensionsTitle": "Extensions", + "@extensionsTitle": { + "description": "Extensions page title" + }, + "extensionsInstalled": "Installed Extensions", + "@extensionsInstalled": { + "description": "Section header for installed extensions" + }, + "extensionsNone": "No extensions installed", + "@extensionsNone": { + "description": "Empty state title" + }, + "extensionsNoneSubtitle": "Install extensions from the Store tab", + "@extensionsNoneSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsEnabled": "Enabled", + "@extensionsEnabled": { + "description": "Extension status - active" + }, + "extensionsDisabled": "Disabled", + "@extensionsDisabled": { + "description": "Extension status - inactive" + }, + "extensionsVersion": "Version {version}", + "@extensionsVersion": { + "description": "Extension version display", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "extensionsAuthor": "by {author}", + "@extensionsAuthor": { + "description": "Extension author credit", + "placeholders": { + "author": { + "type": "String" + } + } + }, + "extensionsUninstall": "Uninstall", + "@extensionsUninstall": { + "description": "Uninstall extension button" + }, + "extensionsSetAsSearch": "Set as Search Provider", + "@extensionsSetAsSearch": { + "description": "Use extension for search" + }, + "storeTitle": "Extension Store", + "@storeTitle": { + "description": "Store screen title" + }, + "storeSearch": "Search extensions...", + "@storeSearch": { + "description": "Store search placeholder" + }, + "storeInstall": "Install", + "@storeInstall": { + "description": "Install extension button" + }, + "storeInstalled": "Installed", + "@storeInstalled": { + "description": "Already installed badge" + }, + "storeUpdate": "Update", + "@storeUpdate": { + "description": "Update available button" + }, + "aboutTitle": "About", + "@aboutTitle": { + "description": "About page title" + }, + "aboutContributors": "Contributors", + "@aboutContributors": { + "description": "Section for contributors" + }, + "aboutMobileDeveloper": "Mobile version developer", + "@aboutMobileDeveloper": { + "description": "Role description for mobile dev" + }, + "aboutOriginalCreator": "Creator of the original SpotiFLAC", + "@aboutOriginalCreator": { + "description": "Role description for original creator" + }, + "aboutLogoArtist": "The talented artist who created our beautiful app logo!", + "@aboutLogoArtist": { + "description": "Role description for logo artist" + }, + "aboutSpecialThanks": "Special Thanks", + "@aboutSpecialThanks": { + "description": "Section for special thanks" + }, + "aboutLinks": "Links", + "@aboutLinks": { + "description": "Section for external links" + }, + "aboutMobileSource": "Mobile source code", + "@aboutMobileSource": { + "description": "Link to mobile GitHub repo" + }, + "aboutPCSource": "PC source code", + "@aboutPCSource": { + "description": "Link to PC GitHub repo" + }, + "aboutReportIssue": "Report an issue", + "@aboutReportIssue": { + "description": "Link to report bugs" + }, + "aboutReportIssueSubtitle": "Report any problems you encounter", + "@aboutReportIssueSubtitle": { + "description": "Subtitle for report issue" + }, + "aboutFeatureRequest": "Feature request", + "@aboutFeatureRequest": { + "description": "Link to suggest features" + }, + "aboutFeatureRequestSubtitle": "Suggest new features for the app", + "@aboutFeatureRequestSubtitle": { + "description": "Subtitle for feature request" + }, + "aboutSupport": "Support", + "@aboutSupport": { + "description": "Section for support/donation links" + }, + "aboutBuyMeCoffee": "Buy me a coffee", + "@aboutBuyMeCoffee": { + "description": "Donation link" + }, + "aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi", + "@aboutBuyMeCoffeeSubtitle": { + "description": "Subtitle for donation" + }, + "aboutApp": "App", + "@aboutApp": { + "description": "Section for app info" + }, + "aboutVersion": "Version", + "@aboutVersion": { + "description": "Version info label" + }, + "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", + "@aboutBinimumDesc": { + "description": "Credit description for binimum" + }, + "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", + "@aboutSachinsenalDesc": { + "description": "Credit description for sachinsenal0x64" + }, + "aboutDoubleDouble": "DoubleDouble", + "@aboutDoubleDouble": { + "description": "Name of Amazon API service - DO NOT TRANSLATE" + }, + "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", + "@aboutDoubleDoubleDesc": { + "description": "Credit for DoubleDouble API" + }, + "aboutDabMusic": "DAB Music", + "@aboutDabMusic": { + "description": "Name of Qobuz API service - DO NOT TRANSLATE" + }, + "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", + "@aboutDabMusicDesc": { + "description": "Credit for DAB Music API" + }, + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "@aboutAppDescription": { + "description": "App description in header card" + }, + "albumTitle": "Album", + "@albumTitle": { + "description": "Album screen title" + }, + "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", + "@albumTracks": { + "description": "Album track count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "albumDownloadAll": "Download All", + "@albumDownloadAll": { + "description": "Button to download all tracks" + }, + "albumDownloadRemaining": "Download Remaining", + "@albumDownloadRemaining": { + "description": "Button to download remaining tracks" + }, + "playlistTitle": "Playlist", + "@playlistTitle": { + "description": "Playlist screen title" + }, + "artistTitle": "Artist", + "@artistTitle": { + "description": "Artist screen title" + }, + "artistAlbums": "Albums", + "@artistAlbums": { + "description": "Section header for artist albums" + }, + "artistSingles": "Singles & EPs", + "@artistSingles": { + "description": "Section header for singles/EPs" + }, + "artistCompilations": "Compilations", + "@artistCompilations": { + "description": "Section header for compilations" + }, + "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", + "@artistReleases": { + "description": "Artist release count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "artistPopular": "Popular", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} monthly listeners", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, + "trackMetadataTitle": "Track Info", + "@trackMetadataTitle": { + "description": "Track metadata screen title" + }, + "trackMetadataArtist": "Artist", + "@trackMetadataArtist": { + "description": "Metadata field - artist name" + }, + "trackMetadataAlbum": "Album", + "@trackMetadataAlbum": { + "description": "Metadata field - album name" + }, + "trackMetadataDuration": "Duration", + "@trackMetadataDuration": { + "description": "Metadata field - track length" + }, + "trackMetadataQuality": "Quality", + "@trackMetadataQuality": { + "description": "Metadata field - audio quality" + }, + "trackMetadataPath": "File Path", + "@trackMetadataPath": { + "description": "Metadata field - file location" + }, + "trackMetadataDownloadedAt": "Downloaded", + "@trackMetadataDownloadedAt": { + "description": "Metadata field - download date" + }, + "trackMetadataService": "Service", + "@trackMetadataService": { + "description": "Metadata field - download service used" + }, + "trackMetadataPlay": "Play", + "@trackMetadataPlay": { + "description": "Action button - play track" + }, + "trackMetadataShare": "Share", + "@trackMetadataShare": { + "description": "Action button - share track" + }, + "trackMetadataDelete": "Delete", + "@trackMetadataDelete": { + "description": "Action button - delete track" + }, + "trackMetadataRedownload": "Re-download", + "@trackMetadataRedownload": { + "description": "Action button - download again" + }, + "trackMetadataOpenFolder": "Open Folder", + "@trackMetadataOpenFolder": { + "description": "Action button - open containing folder" + }, + "setupTitle": "Welcome to SpotiFLAC", + "@setupTitle": { + "description": "Setup wizard title" + }, + "setupSubtitle": "Let's get you started", + "@setupSubtitle": { + "description": "Setup wizard subtitle" + }, + "setupStoragePermission": "Storage Permission", + "@setupStoragePermission": { + "description": "Storage permission step title" + }, + "setupStoragePermissionSubtitle": "Required to save downloaded files", + "@setupStoragePermissionSubtitle": { + "description": "Explanation for storage permission" + }, + "setupStoragePermissionGranted": "Permission granted", + "@setupStoragePermissionGranted": { + "description": "Status when permission granted" + }, + "setupStoragePermissionDenied": "Permission denied", + "@setupStoragePermissionDenied": { + "description": "Status when permission denied" + }, + "setupGrantPermission": "Grant Permission", + "@setupGrantPermission": { + "description": "Button to request permission" + }, + "setupDownloadLocation": "Download Location", + "@setupDownloadLocation": { + "description": "Download folder step title" + }, + "setupChooseFolder": "Choose Folder", + "@setupChooseFolder": { + "description": "Button to pick folder" + }, + "setupContinue": "Continue", + "@setupContinue": { + "description": "Continue to next step button" + }, + "setupSkip": "Skip for now", + "@setupSkip": { + "description": "Skip current step button" + }, + "setupStorageAccessRequired": "Storage Access Required", + "@setupStorageAccessRequired": { + "description": "Title when storage access needed" + }, + "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", + "@setupStorageAccessMessage": { + "description": "Explanation for storage access" + }, + "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", + "@setupStorageAccessMessageAndroid11": { + "description": "Android 11+ specific explanation" + }, + "setupOpenSettings": "Open Settings", + "@setupOpenSettings": { + "description": "Button to open system settings" + }, + "setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.", + "@setupPermissionDeniedMessage": { + "description": "Error when permission denied" + }, + "setupPermissionRequired": "{permissionType} Permission Required", + "@setupPermissionRequired": { + "description": "Generic permission required title", + "placeholders": { + "permissionType": { + "type": "String", + "description": "Type of permission (Storage/Notification)" + } + } + }, + "setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.", + "@setupPermissionRequiredMessage": { + "description": "Generic permission required message", + "placeholders": { + "permissionType": { + "type": "String" + } + } + }, + "setupSelectDownloadFolder": "Select Download Folder", + "@setupSelectDownloadFolder": { + "description": "Folder selection step title" + }, + "setupUseDefaultFolder": "Use Default Folder?", + "@setupUseDefaultFolder": { + "description": "Dialog title for default folder" + }, + "setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?", + "@setupNoFolderSelected": { + "description": "Prompt when no folder selected" + }, + "setupUseDefault": "Use Default", + "@setupUseDefault": { + "description": "Button to use default folder" + }, + "setupDownloadLocationTitle": "Download Location", + "@setupDownloadLocationTitle": { + "description": "Download location dialog title" + }, + "setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.", + "@setupDownloadLocationIosMessage": { + "description": "iOS-specific folder info" + }, + "setupAppDocumentsFolder": "App Documents Folder", + "@setupAppDocumentsFolder": { + "description": "iOS documents folder option" + }, + "setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app", + "@setupAppDocumentsFolderSubtitle": { + "description": "Subtitle for documents folder" + }, + "setupChooseFromFiles": "Choose from Files", + "@setupChooseFromFiles": { + "description": "iOS file picker option" + }, + "setupChooseFromFilesSubtitle": "Select iCloud or other location", + "@setupChooseFromFilesSubtitle": { + "description": "Subtitle for file picker" + }, + "setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", + "@setupIosEmptyFolderWarning": { + "description": "iOS folder selection warning" + }, + "setupDownloadInFlac": "Download Spotify tracks in FLAC", + "@setupDownloadInFlac": { + "description": "App tagline in setup" + }, + "setupStepStorage": "Storage", + "@setupStepStorage": { + "description": "Setup step indicator - storage" + }, + "setupStepNotification": "Notification", + "@setupStepNotification": { + "description": "Setup step indicator - notification" + }, + "setupStepFolder": "Folder", + "@setupStepFolder": { + "description": "Setup step indicator - folder" + }, + "setupStepSpotify": "Spotify", + "@setupStepSpotify": { + "description": "Setup step indicator - Spotify API" + }, + "setupStepPermission": "Permission", + "@setupStepPermission": { + "description": "Setup step indicator - permission" + }, + "setupStorageGranted": "Storage Permission Granted!", + "@setupStorageGranted": { + "description": "Success message for storage permission" + }, + "setupStorageRequired": "Storage Permission Required", + "@setupStorageRequired": { + "description": "Title when storage permission needed" + }, + "setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", + "@setupStorageDescription": { + "description": "Explanation for storage permission" + }, + "setupNotificationGranted": "Notification Permission Granted!", + "@setupNotificationGranted": { + "description": "Success message for notification permission" + }, + "setupNotificationEnable": "Enable Notifications", + "@setupNotificationEnable": { + "description": "Button to enable notifications" + }, + "setupNotificationDescription": "Get notified when downloads complete or require attention.", + "@setupNotificationDescription": { + "description": "Explanation for notifications" + }, + "setupFolderSelected": "Download Folder Selected!", + "@setupFolderSelected": { + "description": "Success message for folder selection" + }, + "setupFolderChoose": "Choose Download Folder", + "@setupFolderChoose": { + "description": "Button to choose folder" + }, + "setupFolderDescription": "Select a folder where your downloaded music will be saved.", + "@setupFolderDescription": { + "description": "Explanation for folder selection" + }, + "setupChangeFolder": "Change Folder", + "@setupChangeFolder": { + "description": "Button to change selected folder" + }, + "setupSelectFolder": "Select Folder", + "@setupSelectFolder": { + "description": "Button to select folder" + }, + "setupSpotifyApiOptional": "Spotify API (Optional)", + "@setupSpotifyApiOptional": { + "description": "Spotify API step title" + }, + "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", + "@setupSpotifyApiDescription": { + "description": "Explanation for Spotify API" + }, + "setupUseSpotifyApi": "Use Spotify API", + "@setupUseSpotifyApi": { + "description": "Toggle to enable Spotify API" + }, + "setupEnterCredentialsBelow": "Enter your credentials below", + "@setupEnterCredentialsBelow": { + "description": "Prompt to enter credentials" + }, + "setupUsingDeezer": "Using Deezer (no account needed)", + "@setupUsingDeezer": { + "description": "Status when using Deezer" + }, + "setupEnterClientId": "Enter Spotify Client ID", + "@setupEnterClientId": { + "description": "Placeholder for client ID field" + }, + "setupEnterClientSecret": "Enter Spotify Client Secret", + "@setupEnterClientSecret": { + "description": "Placeholder for client secret field" + }, + "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", + "@setupGetFreeCredentials": { + "description": "Info about getting Spotify credentials" + }, + "setupEnableNotifications": "Enable Notifications", + "@setupEnableNotifications": { + "description": "Button to enable notifications" + }, + "setupProceedToNextStep": "You can now proceed to the next step.", + "@setupProceedToNextStep": { + "description": "Message after completing a step" + }, + "setupNotificationProgressDescription": "You will receive download progress notifications.", + "@setupNotificationProgressDescription": { + "description": "Info about notification usage" + }, + "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", + "@setupNotificationBackgroundDescription": { + "description": "Detailed notification explanation" + }, + "setupSkipForNow": "Skip for now", + "@setupSkipForNow": { + "description": "Skip button text" + }, + "setupBack": "Back", + "@setupBack": { + "description": "Back button text" + }, + "setupNext": "Next", + "@setupNext": { + "description": "Next button text" + }, + "setupGetStarted": "Get Started", + "@setupGetStarted": { + "description": "Final setup button" + }, + "setupSkipAndStart": "Skip & Start", + "@setupSkipAndStart": { + "description": "Skip setup and start app" + }, + "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", + "@setupAllowAccessToManageFiles": { + "description": "Instruction for file access permission" + }, + "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", + "@setupGetCredentialsFromSpotify": { + "description": "Link text for Spotify developer portal" + }, + "dialogCancel": "Cancel", + "@dialogCancel": { + "description": "Dialog button - cancel action" + }, + "dialogOk": "OK", + "@dialogOk": { + "description": "Dialog button - confirm/acknowledge" + }, + "dialogSave": "Save", + "@dialogSave": { + "description": "Dialog button - save changes" + }, + "dialogDelete": "Delete", + "@dialogDelete": { + "description": "Dialog button - delete item" + }, + "dialogRetry": "Retry", + "@dialogRetry": { + "description": "Dialog button - retry action" + }, + "dialogClose": "Close", + "@dialogClose": { + "description": "Dialog button - close dialog" + }, + "dialogYes": "Yes", + "@dialogYes": { + "description": "Dialog button - confirm yes" + }, + "dialogNo": "No", + "@dialogNo": { + "description": "Dialog button - confirm no" + }, + "dialogClear": "Clear", + "@dialogClear": { + "description": "Dialog button - clear items" + }, + "dialogConfirm": "Confirm", + "@dialogConfirm": { + "description": "Dialog button - confirm action" + }, + "dialogDone": "Done", + "@dialogDone": { + "description": "Dialog button - action completed" + }, + "dialogImport": "Import", + "@dialogImport": { + "description": "Dialog button - import data" + }, + "dialogDiscard": "Discard", + "@dialogDiscard": { + "description": "Dialog button - discard changes" + }, + "dialogRemove": "Remove", + "@dialogRemove": { + "description": "Dialog button - remove item" + }, + "dialogUninstall": "Uninstall", + "@dialogUninstall": { + "description": "Dialog button - uninstall extension" + }, + "dialogDiscardChanges": "Discard Changes?", + "@dialogDiscardChanges": { + "description": "Dialog title - unsaved changes warning" + }, + "dialogUnsavedChanges": "You have unsaved changes. Do you want to discard them?", + "@dialogUnsavedChanges": { + "description": "Dialog message - unsaved changes" + }, + "dialogDownloadFailed": "Download Failed", + "@dialogDownloadFailed": { + "description": "Dialog title - download error" + }, + "dialogTrackLabel": "Track:", + "@dialogTrackLabel": { + "description": "Label for track name in error dialog" + }, + "dialogArtistLabel": "Artist:", + "@dialogArtistLabel": { + "description": "Label for artist name in error dialog" + }, + "dialogErrorLabel": "Error:", + "@dialogErrorLabel": { + "description": "Label for error message" + }, + "dialogClearAll": "Clear All", + "@dialogClearAll": { + "description": "Dialog title - clear all items" + }, + "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", + "@dialogClearAllDownloads": { + "description": "Dialog message - clear downloads confirmation" + }, + "dialogRemoveFromDevice": "Remove from device?", + "@dialogRemoveFromDevice": { + "description": "Dialog title - delete file confirmation" + }, + "dialogRemoveExtension": "Remove Extension", + "@dialogRemoveExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.", + "@dialogRemoveExtensionMessage": { + "description": "Dialog message - uninstall confirmation" + }, + "dialogUninstallExtension": "Uninstall Extension?", + "@dialogUninstallExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?", + "@dialogUninstallExtensionMessage": { + "description": "Dialog message - uninstall specific extension", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "dialogClearHistoryTitle": "Clear History", + "@dialogClearHistoryTitle": { + "description": "Dialog title - clear download history" + }, + "dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.", + "@dialogClearHistoryMessage": { + "description": "Dialog message - clear history confirmation" + }, + "dialogDeleteSelectedTitle": "Delete Selected", + "@dialogDeleteSelectedTitle": { + "description": "Dialog title - delete selected items" + }, + "dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.", + "@dialogDeleteSelectedMessage": { + "description": "Dialog message - delete selected tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dialogImportPlaylistTitle": "Import Playlist", + "@dialogImportPlaylistTitle": { + "description": "Dialog title - import CSV playlist" + }, + "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", + "@dialogImportPlaylistMessage": { + "description": "Dialog message - import playlist confirmation", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAddedToQueue": "Added \"{trackName}\" to queue", + "@snackbarAddedToQueue": { + "description": "Snackbar - track added to download queue", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarAddedTracksToQueue": "Added {count} tracks to queue", + "@snackbarAddedTracksToQueue": { + "description": "Snackbar - multiple tracks added to queue", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded", + "@snackbarAlreadyDownloaded": { + "description": "Snackbar - track already exists", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarHistoryCleared": "History cleared", + "@snackbarHistoryCleared": { + "description": "Snackbar - history deleted" + }, + "snackbarCredentialsSaved": "Credentials saved", + "@snackbarCredentialsSaved": { + "description": "Snackbar - Spotify credentials saved" + }, + "snackbarCredentialsCleared": "Credentials cleared", + "@snackbarCredentialsCleared": { + "description": "Snackbar - Spotify credentials removed" + }, + "snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}", + "@snackbarDeletedTracks": { + "description": "Snackbar - tracks deleted", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarCannotOpenFile": "Cannot open file: {error}", + "@snackbarCannotOpenFile": { + "description": "Snackbar - file open error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarFillAllFields": "Please fill all fields", + "@snackbarFillAllFields": { + "description": "Snackbar - validation error" + }, + "snackbarViewQueue": "View Queue", + "@snackbarViewQueue": { + "description": "Snackbar action - view download queue" + }, + "snackbarFailedToLoad": "Failed to load: {error}", + "@snackbarFailedToLoad": { + "description": "Snackbar - loading error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarUrlCopied": "{platform} URL copied to clipboard", + "@snackbarUrlCopied": { + "description": "Snackbar - URL copied", + "placeholders": { + "platform": { + "type": "String", + "description": "Platform name (Spotify/Deezer)" + } + } + }, + "snackbarFileNotFound": "File not found", + "@snackbarFileNotFound": { + "description": "Snackbar - file doesn't exist" + }, + "snackbarSelectExtFile": "Please select a .spotiflac-ext file", + "@snackbarSelectExtFile": { + "description": "Snackbar - wrong file type selected" + }, + "snackbarProviderPrioritySaved": "Provider priority saved", + "@snackbarProviderPrioritySaved": { + "description": "Snackbar - provider order saved" + }, + "snackbarMetadataProviderSaved": "Metadata provider priority saved", + "@snackbarMetadataProviderSaved": { + "description": "Snackbar - metadata provider order saved" + }, + "snackbarExtensionInstalled": "{extensionName} installed.", + "@snackbarExtensionInstalled": { + "description": "Snackbar - extension installed successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarExtensionUpdated": "{extensionName} updated.", + "@snackbarExtensionUpdated": { + "description": "Snackbar - extension updated successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarFailedToInstall": "Failed to install extension", + "@snackbarFailedToInstall": { + "description": "Snackbar - extension install error" + }, + "snackbarFailedToUpdate": "Failed to update extension", + "@snackbarFailedToUpdate": { + "description": "Snackbar - extension update error" + }, + "errorRateLimited": "Rate Limited", + "@errorRateLimited": { + "description": "Error title - too many requests" + }, + "errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", + "@errorRateLimitedMessage": { + "description": "Error message - rate limit explanation" + }, + "errorFailedToLoad": "Failed to load {item}", + "@errorFailedToLoad": { + "description": "Error message - loading failed", + "placeholders": { + "item": { + "type": "String", + "description": "Item that failed to load (album/playlist/etc)" + } + } + }, + "errorNoTracksFound": "No tracks found", + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, + "errorMissingExtensionSource": "Cannot load {item}: missing extension source", + "@errorMissingExtensionSource": { + "description": "Error - extension source not available", + "placeholders": { + "item": { + "type": "String" + } + } + }, + "statusQueued": "Queued", + "@statusQueued": { + "description": "Download status - waiting in queue" + }, + "statusDownloading": "Downloading", + "@statusDownloading": { + "description": "Download status - in progress" + }, + "statusFinalizing": "Finalizing", + "@statusFinalizing": { + "description": "Download status - writing metadata" + }, + "statusCompleted": "Completed", + "@statusCompleted": { + "description": "Download status - finished" + }, + "statusFailed": "Failed", + "@statusFailed": { + "description": "Download status - error occurred" + }, + "statusSkipped": "Skipped", + "@statusSkipped": { + "description": "Download status - already exists" + }, + "statusPaused": "Paused", + "@statusPaused": { + "description": "Download status - paused" + }, + "actionPause": "Pause", + "@actionPause": { + "description": "Action button - pause download" + }, + "actionResume": "Resume", + "@actionResume": { + "description": "Action button - resume download" + }, + "actionCancel": "Cancel", + "@actionCancel": { + "description": "Action button - cancel operation" + }, + "actionStop": "Stop", + "@actionStop": { + "description": "Action button - stop operation" + }, + "actionSelect": "Select", + "@actionSelect": { + "description": "Action button - enter selection mode" + }, + "actionSelectAll": "Select All", + "@actionSelectAll": { + "description": "Action button - select all items" + }, + "actionDeselect": "Deselect", + "@actionDeselect": { + "description": "Action button - deselect all" + }, + "actionPaste": "Paste", + "@actionPaste": { + "description": "Action button - paste from clipboard" + }, + "actionImportCsv": "Import CSV", + "@actionImportCsv": { + "description": "Action button - import CSV file" + }, + "actionRemoveCredentials": "Remove Credentials", + "@actionRemoveCredentials": { + "description": "Action button - delete Spotify credentials" + }, + "actionSaveCredentials": "Save Credentials", + "@actionSaveCredentials": { + "description": "Action button - save Spotify credentials" + }, + "selectionSelected": "{count} selected", + "@selectionSelected": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionAllSelected": "All tracks selected", + "@selectionAllSelected": { + "description": "Status - all items selected" + }, + "selectionTapToSelect": "Tap tracks to select", + "@selectionTapToSelect": { + "description": "Hint - how to select items" + }, + "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", + "@selectionDeleteTracks": { + "description": "Delete button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionSelectToDelete": "Select tracks to delete", + "@selectionSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "progressFetchingMetadata": "Fetching metadata... {current}/{total}", + "@progressFetchingMetadata": { + "description": "Progress indicator - loading track info", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "progressReadingCsv": "Reading CSV...", + "@progressReadingCsv": { + "description": "Progress indicator - parsing CSV file" + }, + "searchSongs": "Songs", + "@searchSongs": { + "description": "Search result category - songs" + }, + "searchArtists": "Artists", + "@searchArtists": { + "description": "Search result category - artists" + }, + "searchAlbums": "Albums", + "@searchAlbums": { + "description": "Search result category - albums" + }, + "searchPlaylists": "Playlists", + "@searchPlaylists": { + "description": "Search result category - playlists" + }, + "tooltipPlay": "Play", + "@tooltipPlay": { + "description": "Tooltip - play button" + }, + "tooltipCancel": "Cancel", + "@tooltipCancel": { + "description": "Tooltip - cancel button" + }, + "tooltipStop": "Stop", + "@tooltipStop": { + "description": "Tooltip - stop button" + }, + "tooltipRetry": "Retry", + "@tooltipRetry": { + "description": "Tooltip - retry button" + }, + "tooltipRemove": "Remove", + "@tooltipRemove": { + "description": "Tooltip - remove button" + }, + "tooltipClear": "Clear", + "@tooltipClear": { + "description": "Tooltip - clear button" + }, + "tooltipPaste": "Paste", + "@tooltipPaste": { + "description": "Tooltip - paste button" + }, + "filenameFormat": "Filename Format", + "@filenameFormat": { + "description": "Setting title - filename pattern" + }, + "filenameFormatPreview": "Preview: {preview}", + "@filenameFormatPreview": { + "description": "Preview of filename pattern", + "placeholders": { + "preview": { + "type": "String" + } + } + }, + "filenameAvailablePlaceholders": "Available placeholders:", + "@filenameAvailablePlaceholders": { + "description": "Label for placeholder list" + }, + "filenameHint": "{artist} - {title}", + "@filenameHint": { + "description": "Default filename format hint" + }, + "folderOrganization": "Folder Organization", + "@folderOrganization": { + "description": "Setting title - folder structure" + }, + "folderOrganizationNone": "No organization", + "@folderOrganizationNone": { + "description": "Folder option - flat structure" + }, + "folderOrganizationByArtist": "By Artist", + "@folderOrganizationByArtist": { + "description": "Folder option - artist folders" + }, + "folderOrganizationByAlbum": "By Album", + "@folderOrganizationByAlbum": { + "description": "Folder option - album folders" + }, + "folderOrganizationByArtistAlbum": "Artist/Album", + "@folderOrganizationByArtistAlbum": { + "description": "Folder option - nested folders" + }, + "folderOrganizationDescription": "Organize downloaded files into folders", + "@folderOrganizationDescription": { + "description": "Folder organization sheet description" + }, + "folderOrganizationNoneSubtitle": "All files in download folder", + "@folderOrganizationNoneSubtitle": { + "description": "Subtitle for no organization option" + }, + "folderOrganizationByArtistSubtitle": "Separate folder for each artist", + "@folderOrganizationByArtistSubtitle": { + "description": "Subtitle for artist folder option" + }, + "folderOrganizationByAlbumSubtitle": "Separate folder for each album", + "@folderOrganizationByAlbumSubtitle": { + "description": "Subtitle for album folder option" + }, + "folderOrganizationByArtistAlbumSubtitle": "Nested folders for artist and album", + "@folderOrganizationByArtistAlbumSubtitle": { + "description": "Subtitle for nested folder option" + }, + "updateAvailable": "Update Available", + "@updateAvailable": { + "description": "Update dialog title" + }, + "updateNewVersion": "Version {version} is available", + "@updateNewVersion": { + "description": "Update available message", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "updateDownload": "Download", + "@updateDownload": { + "description": "Update button - download update" + }, + "updateLater": "Later", + "@updateLater": { + "description": "Update button - dismiss" + }, + "updateChangelog": "Changelog", + "@updateChangelog": { + "description": "Link to changelog" + }, + "updateStartingDownload": "Starting download...", + "@updateStartingDownload": { + "description": "Update status - initializing" + }, + "updateDownloadFailed": "Download failed", + "@updateDownloadFailed": { + "description": "Update error title" + }, + "updateFailedMessage": "Failed to download update", + "@updateFailedMessage": { + "description": "Update error message" + }, + "updateNewVersionReady": "A new version is ready", + "@updateNewVersionReady": { + "description": "Update subtitle" + }, + "updateCurrent": "Current", + "@updateCurrent": { + "description": "Label for current version" + }, + "updateNew": "New", + "@updateNew": { + "description": "Label for new version" + }, + "updateDownloading": "Downloading...", + "@updateDownloading": { + "description": "Update status - downloading" + }, + "updateWhatsNew": "What's New", + "@updateWhatsNew": { + "description": "Changelog section title" + }, + "updateDownloadInstall": "Download & Install", + "@updateDownloadInstall": { + "description": "Update button - download and install" + }, + "updateDontRemind": "Don't remind", + "@updateDontRemind": { + "description": "Update button - skip this version" + }, + "providerPriority": "Provider Priority", + "@providerPriority": { + "description": "Setting title - download provider order" + }, + "providerPrioritySubtitle": "Drag to reorder download providers", + "@providerPrioritySubtitle": { + "description": "Subtitle for provider priority" + }, + "providerPriorityTitle": "Provider Priority", + "@providerPriorityTitle": { + "description": "Provider priority page title" + }, + "providerPriorityDescription": "Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.", + "@providerPriorityDescription": { + "description": "Provider priority page description" + }, + "providerPriorityInfo": "If a track is not available on the first provider, the app will automatically try the next one.", + "@providerPriorityInfo": { + "description": "Info tip about fallback behavior" + }, + "providerBuiltIn": "Built-in", + "@providerBuiltIn": { + "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + }, + "providerExtension": "Extension", + "@providerExtension": { + "description": "Label for extension-provided providers" + }, + "metadataProviderPriority": "Metadata Provider Priority", + "@metadataProviderPriority": { + "description": "Setting title - metadata provider order" + }, + "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", + "@metadataProviderPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "metadataProviderPriorityTitle": "Metadata Priority", + "@metadataProviderPriorityTitle": { + "description": "Metadata priority page title" + }, + "metadataProviderPriorityDescription": "Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.", + "@metadataProviderPriorityDescription": { + "description": "Metadata priority page description" + }, + "metadataProviderPriorityInfo": "Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.", + "@metadataProviderPriorityInfo": { + "description": "Info tip about rate limits" + }, + "metadataNoRateLimits": "No rate limits", + "@metadataNoRateLimits": { + "description": "Deezer provider description" + }, + "metadataMayRateLimit": "May rate limit", + "@metadataMayRateLimit": { + "description": "Spotify provider description" + }, + "logTitle": "Logs", + "@logTitle": { + "description": "Logs screen title" + }, + "logCopy": "Copy Logs", + "@logCopy": { + "description": "Action - copy logs to clipboard" + }, + "logClear": "Clear Logs", + "@logClear": { + "description": "Action - delete all logs" + }, + "logShare": "Share Logs", + "@logShare": { + "description": "Action - share logs file" + }, + "logEmpty": "No logs yet", + "@logEmpty": { + "description": "Empty state title" + }, + "logCopied": "Logs copied to clipboard", + "@logCopied": { + "description": "Snackbar - logs copied" + }, + "logSearchHint": "Search logs...", + "@logSearchHint": { + "description": "Log search placeholder" + }, + "logFilterLevel": "Level", + "@logFilterLevel": { + "description": "Filter by log level" + }, + "logFilterSection": "Filter", + "@logFilterSection": { + "description": "Filter section title" + }, + "logShareLogs": "Share logs", + "@logShareLogs": { + "description": "Share button tooltip" + }, + "logClearLogs": "Clear logs", + "@logClearLogs": { + "description": "Clear button tooltip" + }, + "logClearLogsTitle": "Clear Logs", + "@logClearLogsTitle": { + "description": "Clear logs dialog title" + }, + "logClearLogsMessage": "Are you sure you want to clear all logs?", + "@logClearLogsMessage": { + "description": "Clear logs confirmation message" + }, + "logIspBlocking": "ISP BLOCKING DETECTED", + "@logIspBlocking": { + "description": "Error category - ISP blocking" + }, + "logRateLimited": "RATE LIMITED", + "@logRateLimited": { + "description": "Error category - rate limiting" + }, + "logNetworkError": "NETWORK ERROR", + "@logNetworkError": { + "description": "Error category - network issues" + }, + "logTrackNotFound": "TRACK NOT FOUND", + "@logTrackNotFound": { + "description": "Error category - missing tracks" + }, + "logFilterBySeverity": "Filter logs by severity", + "@logFilterBySeverity": { + "description": "Filter dialog title" + }, + "logNoLogsYet": "No logs yet", + "@logNoLogsYet": { + "description": "Empty state title" + }, + "logNoLogsYetSubtitle": "Logs will appear here as you use the app", + "@logNoLogsYetSubtitle": { + "description": "Empty state subtitle" + }, + "logIssueSummary": "Issue Summary", + "@logIssueSummary": { + "description": "Section header for error summary" + }, + "logIspBlockingDescription": "Your ISP may be blocking access to download services", + "@logIspBlockingDescription": { + "description": "ISP blocking explanation" + }, + "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", + "@logIspBlockingSuggestion": { + "description": "ISP blocking fix suggestion" + }, + "logRateLimitedDescription": "Too many requests to the service", + "@logRateLimitedDescription": { + "description": "Rate limit explanation" + }, + "logRateLimitedSuggestion": "Wait a few minutes before trying again", + "@logRateLimitedSuggestion": { + "description": "Rate limit fix suggestion" + }, + "logNetworkErrorDescription": "Connection issues detected", + "@logNetworkErrorDescription": { + "description": "Network error explanation" + }, + "logNetworkErrorSuggestion": "Check your internet connection", + "@logNetworkErrorSuggestion": { + "description": "Network error fix suggestion" + }, + "logTrackNotFoundDescription": "Some tracks could not be found on download services", + "@logTrackNotFoundDescription": { + "description": "Track not found explanation" + }, + "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", + "@logTrackNotFoundSuggestion": { + "description": "Track not found explanation" + }, + "logTotalErrors": "Total errors: {count}", + "@logTotalErrors": { + "description": "Error count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logAffected": "Affected: {domains}", + "@logAffected": { + "description": "Affected domains display", + "placeholders": { + "domains": { + "type": "String" + } + } + }, + "logEntriesFiltered": "Entries ({count} filtered)", + "@logEntriesFiltered": { + "description": "Log count with filter active", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logEntries": "Entries ({count})", + "@logEntries": { + "description": "Total log count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "credentialsTitle": "Spotify Credentials", + "@credentialsTitle": { + "description": "Credentials dialog title" + }, + "credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.", + "@credentialsDescription": { + "description": "Credentials dialog explanation" + }, + "credentialsClientId": "Client ID", + "@credentialsClientId": { + "description": "Client ID field label - DO NOT TRANSLATE" + }, + "credentialsClientIdHint": "Paste Client ID", + "@credentialsClientIdHint": { + "description": "Client ID placeholder" + }, + "credentialsClientSecret": "Client Secret", + "@credentialsClientSecret": { + "description": "Client Secret field label - DO NOT TRANSLATE" + }, + "credentialsClientSecretHint": "Paste Client Secret", + "@credentialsClientSecretHint": { + "description": "Client Secret placeholder" + }, + "channelStable": "Stable", + "@channelStable": { + "description": "Update channel - stable releases" + }, + "channelPreview": "Preview", + "@channelPreview": { + "description": "Update channel - beta/preview releases" + }, + "sectionSearchSource": "Search Source", + "@sectionSearchSource": { + "description": "Settings section header" + }, + "sectionDownload": "Download", + "@sectionDownload": { + "description": "Settings section header" + }, + "sectionPerformance": "Performance", + "@sectionPerformance": { + "description": "Settings section header" + }, + "sectionApp": "App", + "@sectionApp": { + "description": "Settings section header" + }, + "sectionData": "Data", + "@sectionData": { + "description": "Settings section header" + }, + "sectionDebug": "Debug", + "@sectionDebug": { + "description": "Settings section header" + }, + "sectionService": "Service", + "@sectionService": { + "description": "Settings section header" + }, + "sectionAudioQuality": "Audio Quality", + "@sectionAudioQuality": { + "description": "Settings section header" + }, + "sectionFileSettings": "File Settings", + "@sectionFileSettings": { + "description": "Settings section header" + }, + "sectionColor": "Color", + "@sectionColor": { + "description": "Settings section header" + }, + "sectionTheme": "Theme", + "@sectionTheme": { + "description": "Settings section header" + }, + "sectionLayout": "Layout", + "@sectionLayout": { + "description": "Settings section header" + }, + "sectionLanguage": "Language", + "@sectionLanguage": { + "description": "Settings section header for language" + }, + "appearanceLanguage": "App Language", + "@appearanceLanguage": { + "description": "Language setting title" + }, + "appearanceLanguageSubtitle": "Choose your preferred language", + "@appearanceLanguageSubtitle": { + "description": "Language setting subtitle" + }, + "settingsAppearanceSubtitle": "Theme, colors, display", + "@settingsAppearanceSubtitle": { + "description": "Appearance settings description" + }, + "settingsDownloadSubtitle": "Service, quality, filename format", + "@settingsDownloadSubtitle": { + "description": "Download settings description" + }, + "settingsOptionsSubtitle": "Fallback, lyrics, cover art, updates", + "@settingsOptionsSubtitle": { + "description": "Options settings description" + }, + "settingsExtensionsSubtitle": "Manage download providers", + "@settingsExtensionsSubtitle": { + "description": "Extensions settings description" + }, + "settingsLogsSubtitle": "View app logs for debugging", + "@settingsLogsSubtitle": { + "description": "Logs settings description" + }, + "loadingSharedLink": "Loading shared link...", + "@loadingSharedLink": { + "description": "Status when opening shared URL" + }, + "pressBackAgainToExit": "Press back again to exit", + "@pressBackAgainToExit": { + "description": "Exit confirmation message" + }, + "tracksHeader": "Tracks", + "@tracksHeader": { + "description": "Section header for track list" + }, + "downloadAllCount": "Download All ({count})", + "@downloadAllCount": { + "description": "Download all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "@tracksCount": { + "description": "Track count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trackCopyFilePath": "Copy file path", + "@trackCopyFilePath": { + "description": "Action - copy file path" + }, + "trackRemoveFromDevice": "Remove from device", + "@trackRemoveFromDevice": { + "description": "Action - delete downloaded file" + }, + "trackLoadLyrics": "Load Lyrics", + "@trackLoadLyrics": { + "description": "Action - fetch lyrics" + }, + "trackMetadata": "Metadata", + "@trackMetadata": { + "description": "Tab title - track metadata" + }, + "trackFileInfo": "File Info", + "@trackFileInfo": { + "description": "Tab title - file information" + }, + "trackLyrics": "Lyrics", + "@trackLyrics": { + "description": "Tab title - lyrics" + }, + "trackFileNotFound": "File not found", + "@trackFileNotFound": { + "description": "Error - file doesn't exist" + }, + "trackOpenInDeezer": "Open in Deezer", + "@trackOpenInDeezer": { + "description": "Action - open track in Deezer app" + }, + "trackOpenInSpotify": "Open in Spotify", + "@trackOpenInSpotify": { + "description": "Action - open track in Spotify app" + }, + "trackTrackName": "Track name", + "@trackTrackName": { + "description": "Metadata label - track title" + }, + "trackArtist": "Artist", + "@trackArtist": { + "description": "Metadata label - artist name" + }, + "trackAlbumArtist": "Album artist", + "@trackAlbumArtist": { + "description": "Metadata label - album artist" + }, + "trackAlbum": "Album", + "@trackAlbum": { + "description": "Metadata label - album name" + }, + "trackTrackNumber": "Track number", + "@trackTrackNumber": { + "description": "Metadata label - track number" + }, + "trackDiscNumber": "Disc number", + "@trackDiscNumber": { + "description": "Metadata label - disc number" + }, + "trackDuration": "Duration", + "@trackDuration": { + "description": "Metadata label - track length" + }, + "trackAudioQuality": "Audio quality", + "@trackAudioQuality": { + "description": "Metadata label - audio quality" + }, + "trackReleaseDate": "Release date", + "@trackReleaseDate": { + "description": "Metadata label - release date" + }, + "trackDownloaded": "Downloaded", + "@trackDownloaded": { + "description": "Metadata label - download date" + }, + "trackCopyLyrics": "Copy lyrics", + "@trackCopyLyrics": { + "description": "Action - copy lyrics to clipboard" + }, + "trackLyricsNotAvailable": "Lyrics not available for this track", + "@trackLyricsNotAvailable": { + "description": "Message when lyrics not found" + }, + "trackLyricsTimeout": "Request timed out. Try again later.", + "@trackLyricsTimeout": { + "description": "Message when lyrics request times out" + }, + "trackLyricsLoadFailed": "Failed to load lyrics", + "@trackLyricsLoadFailed": { + "description": "Message when lyrics loading fails" + }, + "trackCopiedToClipboard": "Copied to clipboard", + "@trackCopiedToClipboard": { + "description": "Snackbar - content copied" + }, + "trackDeleteConfirmTitle": "Remove from device?", + "@trackDeleteConfirmTitle": { + "description": "Delete confirmation title" + }, + "trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", + "@trackDeleteConfirmMessage": { + "description": "Delete confirmation message" + }, + "trackCannotOpen": "Cannot open: {message}", + "@trackCannotOpen": { + "description": "Error opening file", + "placeholders": { + "message": { + "type": "String" + } + } + }, + "dateToday": "Today", + "@dateToday": { + "description": "Relative date - today" + }, + "dateYesterday": "Yesterday", + "@dateYesterday": { + "description": "Relative date - yesterday" + }, + "dateDaysAgo": "{count} days ago", + "@dateDaysAgo": { + "description": "Relative date - days ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateWeeksAgo": "{count} weeks ago", + "@dateWeeksAgo": { + "description": "Relative date - weeks ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateMonthsAgo": "{count} months ago", + "@dateMonthsAgo": { + "description": "Relative date - months ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "concurrentSequential": "Sequential", + "@concurrentSequential": { + "description": "Download mode - one at a time" + }, + "concurrentParallel2": "2 Parallel", + "@concurrentParallel2": { + "description": "Download mode - 2 simultaneous" + }, + "concurrentParallel3": "3 Parallel", + "@concurrentParallel3": { + "description": "Download mode - 3 simultaneous" + }, + "tapToSeeError": "Tap to see error details", + "@tapToSeeError": { + "description": "Tooltip for failed download" + }, + "storeFilterAll": "All", + "@storeFilterAll": { + "description": "Store filter - all extensions" + }, + "storeFilterMetadata": "Metadata", + "@storeFilterMetadata": { + "description": "Store filter - metadata providers" + }, + "storeFilterDownload": "Download", + "@storeFilterDownload": { + "description": "Store filter - download providers" + }, + "storeFilterUtility": "Utility", + "@storeFilterUtility": { + "description": "Store filter - utility extensions" + }, + "storeFilterLyrics": "Lyrics", + "@storeFilterLyrics": { + "description": "Store filter - lyrics providers" + }, + "storeFilterIntegration": "Integration", + "@storeFilterIntegration": { + "description": "Store filter - integrations" + }, + "storeClearFilters": "Clear filters", + "@storeClearFilters": { + "description": "Button to clear all filters" + }, + "storeNoResults": "No extensions found", + "@storeNoResults": { + "description": "Empty state when no extensions match filters" + }, + "extensionProviderPriority": "Provider Priority", + "@extensionProviderPriority": { + "description": "Extension capability - provider priority" + }, + "extensionInstallButton": "Install Extension", + "@extensionInstallButton": { + "description": "Button to install extension" + }, + "extensionDefaultProvider": "Default (Deezer/Spotify)", + "@extensionDefaultProvider": { + "description": "Default search provider option" + }, + "extensionDefaultProviderSubtitle": "Use built-in search", + "@extensionDefaultProviderSubtitle": { + "description": "Subtitle for default provider" + }, + "extensionAuthor": "Author", + "@extensionAuthor": { + "description": "Extension detail - author" + }, + "extensionId": "ID", + "@extensionId": { + "description": "Extension detail - unique ID" + }, + "extensionError": "Error", + "@extensionError": { + "description": "Extension detail - error message" + }, + "extensionCapabilities": "Capabilities", + "@extensionCapabilities": { + "description": "Section header - extension features" + }, + "extensionMetadataProvider": "Metadata Provider", + "@extensionMetadataProvider": { + "description": "Capability - provides metadata" + }, + "extensionDownloadProvider": "Download Provider", + "@extensionDownloadProvider": { + "description": "Capability - provides downloads" + }, + "extensionLyricsProvider": "Lyrics Provider", + "@extensionLyricsProvider": { + "description": "Capability - provides lyrics" + }, + "extensionUrlHandler": "URL Handler", + "@extensionUrlHandler": { + "description": "Capability - handles URLs" + }, + "extensionQualityOptions": "Quality Options", + "@extensionQualityOptions": { + "description": "Capability - quality selection" + }, + "extensionPostProcessingHooks": "Post-Processing Hooks", + "@extensionPostProcessingHooks": { + "description": "Capability - post-processing" + }, + "extensionPermissions": "Permissions", + "@extensionPermissions": { + "description": "Section header - required permissions" + }, + "extensionSettings": "Settings", + "@extensionSettings": { + "description": "Section header - extension settings" + }, + "extensionRemoveButton": "Remove Extension", + "@extensionRemoveButton": { + "description": "Button to uninstall extension" + }, + "extensionUpdated": "Updated", + "@extensionUpdated": { + "description": "Extension detail - last update" + }, + "extensionMinAppVersion": "Min App Version", + "@extensionMinAppVersion": { + "description": "Extension detail - minimum app version" + }, + "extensionCustomTrackMatching": "Custom Track Matching", + "@extensionCustomTrackMatching": { + "description": "Capability - custom track matching algorithm" + }, + "extensionPostProcessing": "Post-Processing", + "@extensionPostProcessing": { + "description": "Capability - post-download processing" + }, + "extensionHooksAvailable": "{count} hook(s) available", + "@extensionHooksAvailable": { + "description": "Post-processing hooks count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionPatternsCount": "{count} pattern(s)", + "@extensionPatternsCount": { + "description": "URL patterns count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionStrategy": "Strategy: {strategy}", + "@extensionStrategy": { + "description": "Track matching strategy name", + "placeholders": { + "strategy": { + "type": "String" + } + } + }, + "extensionsProviderPrioritySection": "Provider Priority", + "@extensionsProviderPrioritySection": { + "description": "Section header - provider priority" + }, + "extensionsInstalledSection": "Installed Extensions", + "@extensionsInstalledSection": { + "description": "Section header - installed extensions" + }, + "extensionsNoExtensions": "No extensions installed", + "@extensionsNoExtensions": { + "description": "Empty state - no extensions" + }, + "extensionsNoExtensionsSubtitle": "Install .spotiflac-ext files to add new providers", + "@extensionsNoExtensionsSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsInstallButton": "Install Extension", + "@extensionsInstallButton": { + "description": "Button to install extension from file" + }, + "extensionsInfoTip": "Extensions can add new metadata and download providers. Only install extensions from trusted sources.", + "@extensionsInfoTip": { + "description": "Security warning about extensions" + }, + "extensionsInstalledSuccess": "Extension installed successfully", + "@extensionsInstalledSuccess": { + "description": "Success message after install" + }, + "extensionsDownloadPriority": "Download Priority", + "@extensionsDownloadPriority": { + "description": "Setting - download provider order" + }, + "extensionsDownloadPrioritySubtitle": "Set download service order", + "@extensionsDownloadPrioritySubtitle": { + "description": "Subtitle for download priority" + }, + "extensionsNoDownloadProvider": "No extensions with download provider", + "@extensionsNoDownloadProvider": { + "description": "Empty state - no download providers" + }, + "extensionsMetadataPriority": "Metadata Priority", + "@extensionsMetadataPriority": { + "description": "Setting - metadata provider order" + }, + "extensionsMetadataPrioritySubtitle": "Set search & metadata source order", + "@extensionsMetadataPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "extensionsNoMetadataProvider": "No extensions with metadata provider", + "@extensionsNoMetadataProvider": { + "description": "Empty state - no metadata providers" + }, + "extensionsSearchProvider": "Search Provider", + "@extensionsSearchProvider": { + "description": "Setting - search provider selection" + }, + "extensionsNoCustomSearch": "No extensions with custom search", + "@extensionsNoCustomSearch": { + "description": "Empty state - no search providers" + }, + "extensionsSearchProviderDescription": "Choose which service to use for searching tracks", + "@extensionsSearchProviderDescription": { + "description": "Search provider setting description" + }, + "extensionsCustomSearch": "Custom search", + "@extensionsCustomSearch": { + "description": "Label for custom search provider" + }, + "extensionsErrorLoading": "Error loading extension", + "@extensionsErrorLoading": { + "description": "Error message when extension fails to load" + }, + "qualityFlacLossless": "FLAC Lossless", + "@qualityFlacLossless": { + "description": "Quality option - CD quality FLAC" + }, + "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", + "@qualityFlacLosslessSubtitle": { + "description": "Technical spec for lossless" + }, + "qualityHiResFlac": "Hi-Res FLAC", + "@qualityHiResFlac": { + "description": "Quality option - high resolution FLAC" + }, + "qualityHiResFlacSubtitle": "24-bit / up to 96kHz", + "@qualityHiResFlacSubtitle": { + "description": "Technical spec for hi-res" + }, + "qualityHiResFlacMax": "Hi-Res FLAC Max", + "@qualityHiResFlacMax": { + "description": "Quality option - maximum resolution FLAC" + }, + "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", + "@qualityHiResFlacMaxSubtitle": { + "description": "Technical spec for hi-res max" + }, + "qualityNote": "Actual quality depends on track availability from the service", + "@qualityNote": { + "description": "Note about quality availability" + }, + "downloadAskBeforeDownload": "Ask Before Download", + "@downloadAskBeforeDownload": { + "description": "Setting - show quality picker" + }, + "downloadDirectory": "Download Directory", + "@downloadDirectory": { + "description": "Setting - download folder" + }, + "downloadSeparateSinglesFolder": "Separate Singles Folder", + "@downloadSeparateSinglesFolder": { + "description": "Setting - separate folder for singles" + }, + "downloadAlbumFolderStructure": "Album Folder Structure", + "@downloadAlbumFolderStructure": { + "description": "Setting - album folder organization" + }, + "downloadSaveFormat": "Save Format", + "@downloadSaveFormat": { + "description": "Setting - output file format" + }, + "downloadSelectService": "Select Service", + "@downloadSelectService": { + "description": "Dialog title - choose download service" + }, + "downloadSelectQuality": "Select Quality", + "@downloadSelectQuality": { + "description": "Dialog title - choose audio quality" + }, + "downloadFrom": "Download From", + "@downloadFrom": { + "description": "Label - download source" + }, + "downloadDefaultQualityLabel": "Default Quality", + "@downloadDefaultQualityLabel": { + "description": "Label - default quality setting" + }, + "downloadBestAvailable": "Best available", + "@downloadBestAvailable": { + "description": "Quality option - highest available" + }, + "folderNone": "None", + "@folderNone": { + "description": "Folder option - no organization" + }, + "folderNoneSubtitle": "Save all files directly to download folder", + "@folderNoneSubtitle": { + "description": "Subtitle for no folder organization" + }, + "folderArtist": "Artist", + "@folderArtist": { + "description": "Folder option - by artist" + }, + "folderArtistSubtitle": "Artist Name/filename", + "@folderArtistSubtitle": { + "description": "Folder structure example" + }, + "folderAlbum": "Album", + "@folderAlbum": { + "description": "Folder option - by album" + }, + "folderAlbumSubtitle": "Album Name/filename", + "@folderAlbumSubtitle": { + "description": "Folder structure example" + }, + "folderArtistAlbum": "Artist/Album", + "@folderArtistAlbum": { + "description": "Folder option - nested" + }, + "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", + "@folderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "serviceTidal": "Tidal", + "@serviceTidal": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceQobuz": "Qobuz", + "@serviceQobuz": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceAmazon": "Amazon", + "@serviceAmazon": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceDeezer": "Deezer", + "@serviceDeezer": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceSpotify": "Spotify", + "@serviceSpotify": { + "description": "Service name - DO NOT TRANSLATE" + }, + "appearanceAmoledDark": "AMOLED Dark", + "@appearanceAmoledDark": { + "description": "Theme option - pure black" + }, + "appearanceAmoledDarkSubtitle": "Pure black background", + "@appearanceAmoledDarkSubtitle": { + "description": "Subtitle for AMOLED dark" + }, + "appearanceChooseAccentColor": "Choose Accent Color", + "@appearanceChooseAccentColor": { + "description": "Color picker dialog title" + }, + "appearanceChooseTheme": "Theme Mode", + "@appearanceChooseTheme": { + "description": "Theme picker dialog title" + }, + "queueTitle": "Download Queue", + "@queueTitle": { + "description": "Queue screen title" + }, + "queueClearAll": "Clear All", + "@queueClearAll": { + "description": "Button - clear all queue items" + }, + "queueClearAllMessage": "Are you sure you want to clear all downloads?", + "@queueClearAllMessage": { + "description": "Clear queue confirmation" + }, + "queueEmpty": "No downloads in queue", + "@queueEmpty": { + "description": "Empty queue state title" + }, + "queueEmptySubtitle": "Add tracks from the home screen", + "@queueEmptySubtitle": { + "description": "Empty queue state subtitle" + }, + "queueClearCompleted": "Clear completed", + "@queueClearCompleted": { + "description": "Button - clear finished downloads" + }, + "queueDownloadFailed": "Download Failed", + "@queueDownloadFailed": { + "description": "Error dialog title" + }, + "queueTrackLabel": "Track:", + "@queueTrackLabel": { + "description": "Label in error dialog" + }, + "queueArtistLabel": "Artist:", + "@queueArtistLabel": { + "description": "Label in error dialog" + }, + "queueErrorLabel": "Error:", + "@queueErrorLabel": { + "description": "Label in error dialog" + }, + "queueUnknownError": "Unknown error", + "@queueUnknownError": { + "description": "Fallback error message" + }, + "albumFolderArtistAlbum": "Artist / Album", + "@albumFolderArtistAlbum": { + "description": "Album folder option" + }, + "albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/", + "@albumFolderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderArtistYearAlbum": "Artist / [Year] Album", + "@albumFolderArtistYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/", + "@albumFolderArtistYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderAlbumOnly": "Album Only", + "@albumFolderAlbumOnly": { + "description": "Album folder option" + }, + "albumFolderAlbumOnlySubtitle": "Albums/Album Name/", + "@albumFolderAlbumOnlySubtitle": { + "description": "Folder structure example" + }, + "albumFolderYearAlbum": "[Year] Album", + "@albumFolderYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", + "@albumFolderYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "downloadedAlbumDeleteSelected": "Delete Selected", + "@downloadedAlbumDeleteSelected": { + "description": "Button - delete selected tracks" + }, + "downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.", + "@downloadedAlbumDeleteMessage": { + "description": "Delete confirmation with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumTracksHeader": "Tracks", + "@downloadedAlbumTracksHeader": { + "description": "Section header for tracks" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectedCount": "{count} selected", + "@downloadedAlbumSelectedCount": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumAllSelected": "All tracks selected", + "@downloadedAlbumAllSelected": { + "description": "Status - all items selected" + }, + "downloadedAlbumTapToSelect": "Tap tracks to select", + "@downloadedAlbumTapToSelect": { + "description": "Selection hint" + }, + "downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}", + "@downloadedAlbumDeleteCount": { + "description": "Delete button text with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectToDelete": "Select tracks to delete", + "@downloadedAlbumSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "utilityFunctions": "Utility Functions", + "@utilityFunctions": { + "description": "Extension capability - utility functions" + }, + "recentTypeArtist": "Artist", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Song", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": { + "type": "String", + "description": "Error message" + } + } + } +} \ No newline at end of file From e9ca0546821af414e59b20e754e15e4a2b12e158 Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:27 +0700 Subject: [PATCH 08/48] New translations app_en.arb (Chinese Simplified) --- lib/l10n/arb/app_zh_CN.arb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index 5b9f70aa..7e545a0b 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh_CN", + "@@locale": "zh-CN", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { From f4d7c6531f7b9cd3d02c8e36fac77134a4fffe4e Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:27 +0700 Subject: [PATCH 09/48] New translations app_en.arb (Chinese Traditional) --- lib/l10n/arb/app_zh_TW.arb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index fdec8f45..8526e88f 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh_TW", + "@@locale": "zh-TW", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { From 07a1c68354ed8fbc98d2d8598cba27a9267543cb Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:28 +0700 Subject: [PATCH 10/48] New translations app_en.arb (Indonesian) --- lib/l10n/arb/app_id.arb | 3018 ++++++++++++++++++++++++++++++++------- 1 file changed, 2476 insertions(+), 542 deletions(-) diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 577f7a41..06af2546 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,681 +1,2615 @@ { "@@locale": "id", "@@last_modified": "2026-01-16", - "appName": "SpotiFLAC", + "@appName": { + "description": "App name - DO NOT TRANSLATE" + }, "appDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", - + "@appDescription": { + "description": "App description shown in about page" + }, "navHome": "Beranda", + "@navHome": { + "description": "Bottom navigation - Home tab" + }, "navHistory": "Riwayat", + "@navHistory": { + "description": "Bottom navigation - History tab" + }, "navSettings": "Pengaturan", + "@navSettings": { + "description": "Bottom navigation - Settings tab" + }, "navStore": "Toko", - + "@navStore": { + "description": "Bottom navigation - Extension store tab" + }, "homeTitle": "Beranda", + "@homeTitle": { + "description": "Home screen title" + }, "homeSearchHint": "Tempel URL Spotify atau cari...", + "@homeSearchHint": { + "description": "Placeholder text in search box" + }, "homeSearchHintExtension": "Cari dengan {extensionName}...", + "@homeSearchHintExtension": { + "description": "Placeholder when extension search is active", + "placeholders": { + "extensionName": { + "type": "String", + "description": "Name of the active extension" + } + } + }, "homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama", + "@homeSubtitle": { + "description": "Subtitle shown below search box" + }, "homeSupports": "Mendukung: URL Track, Album, Playlist, Artis", + "@homeSupports": { + "description": "Info text about supported URL types" + }, "homeRecent": "Terbaru", - + "@homeRecent": { + "description": "Section header for recent searches" + }, "historyTitle": "Riwayat", + "@historyTitle": { + "description": "History screen title" + }, "historyDownloading": "Mengunduh ({count})", + "@historyDownloading": { + "description": "Tab showing active downloads count", + "placeholders": { + "count": { + "type": "int", + "description": "Number of active downloads" + } + } + }, "historyDownloaded": "Terunduh", + "@historyDownloaded": { + "description": "Tab showing completed downloads" + }, "historyFilterAll": "Semua", + "@historyFilterAll": { + "description": "Filter chip - show all items" + }, "historyFilterAlbums": "Album", + "@historyFilterAlbums": { + "description": "Filter chip - show albums only" + }, "historyFilterSingles": "Single", + "@historyFilterSingles": { + "description": "Filter chip - show singles only" + }, "historyTracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "@historyTracksCount": { + "description": "Track count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} album}}", + "@historyAlbumsCount": { + "description": "Album count with plural form", + "placeholders": { + "count": { + "type": "int" + } + } + }, "historyNoDownloads": "Tidak ada riwayat unduhan", + "@historyNoDownloads": { + "description": "Empty state title" + }, "historyNoDownloadsSubtitle": "Lagu yang diunduh akan muncul di sini", + "@historyNoDownloadsSubtitle": { + "description": "Empty state subtitle" + }, "historyNoAlbums": "Tidak ada unduhan album", + "@historyNoAlbums": { + "description": "Empty state when filtering albums" + }, "historyNoAlbumsSubtitle": "Unduh beberapa lagu dari album untuk melihatnya di sini", + "@historyNoAlbumsSubtitle": { + "description": "Empty state subtitle for albums filter" + }, "historyNoSingles": "Tidak ada unduhan single", + "@historyNoSingles": { + "description": "Empty state when filtering singles" + }, "historyNoSinglesSubtitle": "Unduhan lagu satuan akan muncul di sini", - + "@historyNoSinglesSubtitle": { + "description": "Empty state subtitle for singles filter" + }, "settingsTitle": "Pengaturan", + "@settingsTitle": { + "description": "Settings screen title" + }, "settingsDownload": "Unduhan", + "@settingsDownload": { + "description": "Settings section - download options" + }, "settingsAppearance": "Tampilan", + "@settingsAppearance": { + "description": "Settings section - visual customization" + }, "settingsOptions": "Opsi", + "@settingsOptions": { + "description": "Settings section - app options" + }, "settingsExtensions": "Ekstensi", + "@settingsExtensions": { + "description": "Settings section - extension management" + }, "settingsAbout": "Tentang", - + "@settingsAbout": { + "description": "Settings section - app info" + }, "downloadTitle": "Unduhan", + "@downloadTitle": { + "description": "Download settings page title" + }, "downloadLocation": "Lokasi Unduhan", + "@downloadLocation": { + "description": "Setting for download folder" + }, "downloadLocationSubtitle": "Pilih tempat menyimpan file", + "@downloadLocationSubtitle": { + "description": "Subtitle for download location" + }, "downloadLocationDefault": "Lokasi default", + "@downloadLocationDefault": { + "description": "Shown when using default folder" + }, "downloadDefaultService": "Layanan Default", + "@downloadDefaultService": { + "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" + }, "downloadDefaultServiceSubtitle": "Layanan yang digunakan untuk unduhan", + "@downloadDefaultServiceSubtitle": { + "description": "Subtitle for default service" + }, "downloadDefaultQuality": "Kualitas Default", + "@downloadDefaultQuality": { + "description": "Setting for audio quality" + }, "downloadAskQuality": "Tanya Kualitas Sebelum Unduh", + "@downloadAskQuality": { + "description": "Toggle to show quality picker" + }, "downloadAskQualitySubtitle": "Tampilkan pemilih kualitas untuk setiap unduhan", + "@downloadAskQualitySubtitle": { + "description": "Subtitle for ask quality toggle" + }, "downloadFilenameFormat": "Format Nama File", + "@downloadFilenameFormat": { + "description": "Setting for output filename pattern" + }, "downloadFolderOrganization": "Organisasi Folder", + "@downloadFolderOrganization": { + "description": "Setting for folder structure" + }, "downloadSeparateSingles": "Pisahkan Single", + "@downloadSeparateSingles": { + "description": "Toggle to separate single tracks" + }, "downloadSeparateSinglesSubtitle": "Letakkan lagu satuan di folder terpisah", - + "@downloadSeparateSinglesSubtitle": { + "description": "Subtitle for separate singles toggle" + }, "qualityBest": "Terbaik", + "@qualityBest": { + "description": "Audio quality option - highest available" + }, "qualityFlac": "FLAC", + "@qualityFlac": { + "description": "Audio quality option - FLAC lossless" + }, "quality320": "320 kbps", + "@quality320": { + "description": "Audio quality option - 320kbps MP3" + }, "quality128": "128 kbps", - + "@quality128": { + "description": "Audio quality option - 128kbps MP3" + }, "appearanceTitle": "Tampilan", + "@appearanceTitle": { + "description": "Appearance settings page title" + }, "appearanceTheme": "Tema", + "@appearanceTheme": { + "description": "Theme mode setting" + }, "appearanceThemeSystem": "Sistem", + "@appearanceThemeSystem": { + "description": "Follow system theme" + }, "appearanceThemeLight": "Terang", + "@appearanceThemeLight": { + "description": "Light theme" + }, "appearanceThemeDark": "Gelap", + "@appearanceThemeDark": { + "description": "Dark theme" + }, "appearanceDynamicColor": "Warna Dinamis", + "@appearanceDynamicColor": { + "description": "Material You dynamic colors" + }, "appearanceDynamicColorSubtitle": "Gunakan warna dari wallpaper Anda", + "@appearanceDynamicColorSubtitle": { + "description": "Subtitle for dynamic color" + }, "appearanceAccentColor": "Warna Aksen", + "@appearanceAccentColor": { + "description": "Custom accent color picker" + }, "appearanceHistoryView": "Tampilan Riwayat", + "@appearanceHistoryView": { + "description": "Layout style for history" + }, "appearanceHistoryViewList": "Daftar", + "@appearanceHistoryViewList": { + "description": "List layout option" + }, "appearanceHistoryViewGrid": "Grid", - + "@appearanceHistoryViewGrid": { + "description": "Grid layout option" + }, "optionsTitle": "Opsi", + "@optionsTitle": { + "description": "Options settings page title" + }, "optionsSearchSource": "Sumber Pencarian", + "@optionsSearchSource": { + "description": "Section for search provider settings" + }, "optionsPrimaryProvider": "Provider Utama", + "@optionsPrimaryProvider": { + "description": "Main search provider setting" + }, "optionsPrimaryProviderSubtitle": "Layanan yang digunakan saat mencari berdasarkan nama lagu.", + "@optionsPrimaryProviderSubtitle": { + "description": "Subtitle for primary provider" + }, "optionsUsingExtension": "Menggunakan ekstensi: {extensionName}", + "@optionsUsingExtension": { + "description": "Shows active extension name", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, "optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi", + "@optionsSwitchBack": { + "description": "Hint to switch back to built-in providers" + }, "optionsAutoFallback": "Auto Fallback", + "@optionsAutoFallback": { + "description": "Auto-retry with other services" + }, "optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal", + "@optionsAutoFallbackSubtitle": { + "description": "Subtitle for auto fallback" + }, "optionsUseExtensionProviders": "Gunakan Provider Ekstensi", + "@optionsUseExtensionProviders": { + "description": "Enable extension download providers" + }, "optionsUseExtensionProvidersOn": "Ekstensi akan dicoba terlebih dahulu", + "@optionsUseExtensionProvidersOn": { + "description": "Status when extension providers enabled" + }, "optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan", + "@optionsUseExtensionProvidersOff": { + "description": "Status when extension providers disabled" + }, "optionsEmbedLyrics": "Sematkan Lirik", + "@optionsEmbedLyrics": { + "description": "Embed lyrics in audio files" + }, "optionsEmbedLyricsSubtitle": "Sematkan lirik sinkron ke file FLAC", + "@optionsEmbedLyricsSubtitle": { + "description": "Subtitle for embed lyrics" + }, "optionsMaxQualityCover": "Cover Kualitas Maksimal", + "@optionsMaxQualityCover": { + "description": "Download highest quality album art" + }, "optionsMaxQualityCoverSubtitle": "Unduh cover art resolusi tertinggi", + "@optionsMaxQualityCoverSubtitle": { + "description": "Subtitle for max quality cover" + }, "optionsConcurrentDownloads": "Unduhan Bersamaan", + "@optionsConcurrentDownloads": { + "description": "Number of parallel downloads" + }, "optionsConcurrentSequential": "Berurutan (1 per waktu)", + "@optionsConcurrentSequential": { + "description": "Download one at a time" + }, "optionsConcurrentParallel": "{count} unduhan paralel", + "@optionsConcurrentParallel": { + "description": "Multiple parallel downloads", + "placeholders": { + "count": { + "type": "int" + } + } + }, "optionsConcurrentWarning": "Unduhan paralel dapat memicu pembatasan rate", + "@optionsConcurrentWarning": { + "description": "Warning about rate limits" + }, "optionsExtensionStore": "Toko Ekstensi", + "@optionsExtensionStore": { + "description": "Show/hide store tab" + }, "optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi", + "@optionsExtensionStoreSubtitle": { + "description": "Subtitle for extension store toggle" + }, "optionsCheckUpdates": "Periksa Pembaruan", + "@optionsCheckUpdates": { + "description": "Auto update check toggle" + }, "optionsCheckUpdatesSubtitle": "Beritahu saat versi baru tersedia", + "@optionsCheckUpdatesSubtitle": { + "description": "Subtitle for update check" + }, "optionsUpdateChannel": "Saluran Pembaruan", + "@optionsUpdateChannel": { + "description": "Stable vs preview releases" + }, "optionsUpdateChannelStable": "Hanya rilis stabil", + "@optionsUpdateChannelStable": { + "description": "Only stable updates" + }, "optionsUpdateChannelPreview": "Dapatkan rilis preview", + "@optionsUpdateChannelPreview": { + "description": "Include beta/preview updates" + }, "optionsUpdateChannelWarning": "Preview mungkin mengandung bug atau fitur belum lengkap", + "@optionsUpdateChannelWarning": { + "description": "Warning about preview channel" + }, "optionsClearHistory": "Hapus Riwayat Unduhan", + "@optionsClearHistory": { + "description": "Delete all download history" + }, "optionsClearHistorySubtitle": "Hapus semua lagu dari riwayat", + "@optionsClearHistorySubtitle": { + "description": "Subtitle for clear history" + }, "optionsDetailedLogging": "Log Detail", + "@optionsDetailedLogging": { + "description": "Enable verbose logs for debugging" + }, "optionsDetailedLoggingOn": "Log detail sedang direkam", + "@optionsDetailedLoggingOn": { + "description": "Status when logging enabled" + }, "optionsDetailedLoggingOff": "Aktifkan untuk laporan bug", + "@optionsDetailedLoggingOff": { + "description": "Status when logging disabled" + }, "optionsSpotifyCredentials": "Kredensial Spotify", + "@optionsSpotifyCredentials": { + "description": "Spotify API credentials setting" + }, "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", + "@optionsSpotifyCredentialsConfigured": { + "description": "Shows configured client ID preview", + "placeholders": { + "clientId": { + "type": "String" + } + } + }, "optionsSpotifyCredentialsRequired": "Diperlukan - ketuk untuk mengatur", + "@optionsSpotifyCredentialsRequired": { + "description": "Prompt to set up credentials" + }, "optionsSpotifyWarning": "Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com", - + "@optionsSpotifyWarning": { + "description": "Info about Spotify API requirement" + }, "extensionsTitle": "Ekstensi", + "@extensionsTitle": { + "description": "Extensions page title" + }, "extensionsInstalled": "Ekstensi Terpasang", + "@extensionsInstalled": { + "description": "Section header for installed extensions" + }, "extensionsNone": "Tidak ada ekstensi terpasang", + "@extensionsNone": { + "description": "Empty state title" + }, "extensionsNoneSubtitle": "Pasang ekstensi dari tab Toko", + "@extensionsNoneSubtitle": { + "description": "Empty state subtitle" + }, "extensionsEnabled": "Aktif", + "@extensionsEnabled": { + "description": "Extension status - active" + }, "extensionsDisabled": "Nonaktif", + "@extensionsDisabled": { + "description": "Extension status - inactive" + }, "extensionsVersion": "Versi {version}", + "@extensionsVersion": { + "description": "Extension version display", + "placeholders": { + "version": { + "type": "String" + } + } + }, "extensionsAuthor": "oleh {author}", + "@extensionsAuthor": { + "description": "Extension author credit", + "placeholders": { + "author": { + "type": "String" + } + } + }, "extensionsUninstall": "Copot", + "@extensionsUninstall": { + "description": "Uninstall extension button" + }, "extensionsSetAsSearch": "Jadikan Provider Pencarian", - + "@extensionsSetAsSearch": { + "description": "Use extension for search" + }, "storeTitle": "Toko Ekstensi", + "@storeTitle": { + "description": "Store screen title" + }, "storeSearch": "Cari ekstensi...", + "@storeSearch": { + "description": "Store search placeholder" + }, "storeInstall": "Pasang", + "@storeInstall": { + "description": "Install extension button" + }, "storeInstalled": "Terpasang", + "@storeInstalled": { + "description": "Already installed badge" + }, "storeUpdate": "Perbarui", - + "@storeUpdate": { + "description": "Update available button" + }, "aboutTitle": "Tentang", + "@aboutTitle": { + "description": "About page title" + }, "aboutContributors": "Kontributor", + "@aboutContributors": { + "description": "Section for contributors" + }, "aboutMobileDeveloper": "Pengembang versi mobile", - "aboutOriginalCreator": "Pencipta SpotiFLAC asli", - "aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kami yang indah!", - "aboutSpecialThanks": "Terima Kasih Khusus", - "aboutLinks": "Tautan", - "aboutMobileSource": "Kode sumber mobile", - "aboutPCSource": "Kode sumber PC", - "aboutReportIssue": "Laporkan masalah", - "aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui", - "aboutFeatureRequest": "Permintaan fitur", - "aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi", - "aboutSupport": "Dukungan", - "aboutBuyMeCoffee": "Traktir saya kopi", - "aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi", - "aboutApp": "Aplikasi", - "aboutVersion": "Versi", - - "albumTitle": "Album", - "albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}", - "albumDownloadAll": "Unduh Semua", - "albumDownloadRemaining": "Unduh Sisanya", - - "playlistTitle": "Playlist", - "artistTitle": "Artis", - "artistAlbums": "Album", - "artistSingles": "Single & EP", - - "trackMetadataTitle": "Info Lagu", - "trackMetadataArtist": "Artis", - "trackMetadataAlbum": "Album", - "trackMetadataDuration": "Durasi", - "trackMetadataQuality": "Kualitas", - "trackMetadataPath": "Lokasi File", - "trackMetadataDownloadedAt": "Diunduh", - "trackMetadataService": "Layanan", - "trackMetadataPlay": "Putar", - "trackMetadataShare": "Bagikan", - "trackMetadataDelete": "Hapus", - "trackMetadataRedownload": "Unduh ulang", - "trackMetadataOpenFolder": "Buka Folder", - - "setupTitle": "Selamat Datang di SpotiFLAC", - "setupSubtitle": "Mari mulai pengaturan", - "setupStoragePermission": "Izin Penyimpanan", - "setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan", - "setupStoragePermissionGranted": "Izin diberikan", - "setupStoragePermissionDenied": "Izin ditolak", - "setupGrantPermission": "Berikan Izin", - "setupDownloadLocation": "Lokasi Unduhan", - "setupChooseFolder": "Pilih Folder", - "setupContinue": "Lanjutkan", - "setupSkip": "Lewati untuk sekarang", - - "dialogCancel": "Batal", - "dialogOk": "OK", - "dialogSave": "Simpan", - "dialogDelete": "Hapus", - "dialogRetry": "Coba Lagi", - "dialogClose": "Tutup", - "dialogYes": "Ya", - "dialogNo": "Tidak", - "dialogClear": "Hapus", - "dialogConfirm": "Konfirmasi", - "dialogDone": "Selesai", - - "dialogClearHistoryTitle": "Hapus Riwayat", - "dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.", - "dialogDeleteSelectedTitle": "Hapus yang Dipilih", - "dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.", - "dialogImportPlaylistTitle": "Impor Playlist", - "dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?", - - "snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian", - "snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian", - "snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh", - "snackbarHistoryCleared": "Riwayat dihapus", - "snackbarCredentialsSaved": "Kredensial disimpan", - "snackbarCredentialsCleared": "Kredensial dihapus", - "snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}", - "snackbarCannotOpenFile": "Tidak dapat membuka file: {error}", - "snackbarFillAllFields": "Harap isi semua field", - "snackbarViewQueue": "Lihat Antrian", - - "errorRateLimited": "Dibatasi", - "errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.", - "errorFailedToLoad": "Gagal memuat {item}", - "errorNoTracksFound": "Tidak ada lagu ditemukan", - "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", - - "statusQueued": "Mengantri", - "statusDownloading": "Mengunduh", - "statusFinalizing": "Menyelesaikan", - "statusCompleted": "Selesai", - "statusFailed": "Gagal", - "statusSkipped": "Dilewati", - "statusPaused": "Dijeda", - - "actionPause": "Jeda", - "actionResume": "Lanjutkan", - "actionCancel": "Batal", - "actionStop": "Hentikan", - "actionSelect": "Pilih", - "actionSelectAll": "Pilih Semua", - "actionDeselect": "Batal Pilih", - "actionPaste": "Tempel", - "actionImportCsv": "Impor CSV", - "actionRemoveCredentials": "Hapus Kredensial", - "actionSaveCredentials": "Simpan Kredensial", - - "selectionSelected": "{count} dipilih", - "selectionAllSelected": "Semua lagu dipilih", - "selectionTapToSelect": "Ketuk lagu untuk memilih", - "selectionDeleteTracks": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", - "selectionSelectToDelete": "Pilih lagu untuk dihapus", - - "progressFetchingMetadata": "Mengambil metadata... {current}/{total}", - "progressReadingCsv": "Membaca CSV...", - - "searchSongs": "Lagu", - "searchArtists": "Artis", - "searchAlbums": "Album", - "searchPlaylists": "Playlist", - - "tooltipPlay": "Putar", - "tooltipCancel": "Batal", - "tooltipStop": "Hentikan", - "tooltipRetry": "Coba Lagi", - "tooltipRemove": "Hapus", - "tooltipClear": "Hapus", - "tooltipPaste": "Tempel", - - "filenameFormat": "Format Nama File", - "filenameFormatPreview": "Pratinjau: {preview}", - "folderOrganization": "Organisasi Folder", - "folderOrganizationNone": "Tanpa organisasi", - "folderOrganizationByArtist": "Berdasarkan Artis", - "folderOrganizationByAlbum": "Berdasarkan Album", - "folderOrganizationByArtistAlbum": "Artis/Album", - - "updateAvailable": "Pembaruan Tersedia", - "updateNewVersion": "Versi {version} tersedia", - "updateDownload": "Unduh", - "updateLater": "Nanti", - "updateChangelog": "Log Perubahan", - - "providerPriority": "Prioritas Provider", - "providerPrioritySubtitle": "Seret untuk mengatur ulang provider unduhan", - "metadataProviderPriority": "Prioritas Provider Metadata", - "metadataProviderPrioritySubtitle": "Urutan yang digunakan saat mengambil metadata lagu", - - "logTitle": "Log", - "logCopy": "Salin Log", - "logClear": "Hapus Log", - "logShare": "Bagikan Log", - "logEmpty": "Belum ada log", - "logCopied": "Log disalin ke clipboard", - - "credentialsTitle": "Kredensial Spotify", - "credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.", - "credentialsClientId": "Client ID", - "credentialsClientIdHint": "Tempel Client ID", - "credentialsClientSecret": "Client Secret", - "credentialsClientSecretHint": "Tempel Client Secret", - - "channelStable": "Stabil", - "channelPreview": "Preview", - - "sectionSearchSource": "Sumber Pencarian", - "sectionDownload": "Unduhan", - "sectionPerformance": "Performa", - "sectionApp": "Aplikasi", - "sectionData": "Data", - "sectionDebug": "Debug", - "sectionService": "Layanan", - "sectionAudioQuality": "Kualitas Audio", - "sectionFileSettings": "Pengaturan File", - "sectionColor": "Warna", - "sectionTheme": "Tema", - "sectionLayout": "Tata Letak", - "sectionLanguage": "Bahasa", - - "appearanceLanguage": "Bahasa Aplikasi", - "appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan", - "languageSystem": "Bawaan Sistem", - "languageEnglish": "English", - "languageIndonesian": "Bahasa Indonesia", - - "settingsAppearanceSubtitle": "Tema, warna, tampilan", - "settingsDownloadSubtitle": "Layanan, kualitas, format nama file", - "settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan", - "settingsExtensionsSubtitle": "Kelola provider unduhan", - "settingsLogsSubtitle": "Lihat log aplikasi untuk debugging", - - "loadingSharedLink": "Memuat link yang dibagikan...", - "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", - - "artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}", - "artistCompilations": "Kompilasi", - "artistPopular": "Populer", - "artistMonthlyListeners": "{count} pendengar bulanan", - - "tracksHeader": "Lagu", - "downloadAllCount": "Unduh Semua ({count})", - "tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", - - "setupStorageAccessRequired": "Akses Penyimpanan Diperlukan", - "setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.", - "setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.", - "setupOpenSettings": "Buka Pengaturan", - "setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.", - "setupPermissionRequired": "Izin {permissionType} Diperlukan", - "setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.", - "setupSelectDownloadFolder": "Pilih Folder Unduhan", - "setupUseDefaultFolder": "Gunakan Folder Default?", - "setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?", - "setupUseDefault": "Gunakan Default", - "setupDownloadLocationTitle": "Lokasi Unduhan", - "setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.", - "setupAppDocumentsFolder": "Folder Documents Aplikasi", - "setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files", - "setupChooseFromFiles": "Pilih dari Files", - "setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya", - "setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.", - "setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC", - "setupStepStorage": "Penyimpanan", - "setupStepNotification": "Notifikasi", - "setupStepFolder": "Folder", - "setupStepSpotify": "Spotify", - "setupStepPermission": "Izin", - "setupStorageGranted": "Izin Penyimpanan Diberikan!", - "setupStorageRequired": "Izin Penyimpanan Diperlukan", - "setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.", - "setupNotificationGranted": "Izin Notifikasi Diberikan!", - "setupNotificationEnable": "Aktifkan Notifikasi", - "setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.", - "setupFolderSelected": "Folder Unduhan Dipilih!", - "setupFolderChoose": "Pilih Folder Unduhan", - "setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.", - "setupChangeFolder": "Ubah Folder", - "setupSelectFolder": "Pilih Folder", - "setupSpotifyApiOptional": "Spotify API (Opsional)", - "setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.", - "setupUseSpotifyApi": "Gunakan Spotify API", - "setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah", - "setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)", - "setupEnterClientId": "Masukkan Spotify Client ID", - "setupEnterClientSecret": "Masukkan Spotify Client Secret", - "setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.", - "setupEnableNotifications": "Aktifkan Notifikasi", - - "dialogImport": "Impor", - "dialogDiscard": "Buang", - "dialogRemove": "Hapus", - "dialogUninstall": "Copot", - "dialogDiscardChanges": "Buang Perubahan?", - "dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?", - "dialogDownloadFailed": "Unduhan Gagal", - "dialogTrackLabel": "Lagu:", - "dialogArtistLabel": "Artis:", - "dialogErrorLabel": "Error:", - "dialogClearAll": "Hapus Semua", - "dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?", - "dialogRemoveFromDevice": "Hapus dari perangkat?", - "dialogRemoveExtension": "Hapus Ekstensi", - "dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.", - "dialogUninstallExtension": "Copot Ekstensi?", - "dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?", - - "snackbarFailedToLoad": "Gagal memuat: {error}", - "snackbarUrlCopied": "URL {platform} disalin ke clipboard", - "snackbarFileNotFound": "File tidak ditemukan", - "snackbarSelectExtFile": "Harap pilih file .spotiflac-ext", - "snackbarProviderPrioritySaved": "Prioritas provider disimpan", - "snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan", - "snackbarExtensionInstalled": "{extensionName} terpasang.", - "snackbarExtensionUpdated": "{extensionName} diperbarui.", - "snackbarFailedToInstall": "Gagal memasang ekstensi", - "snackbarFailedToUpdate": "Gagal memperbarui ekstensi", - - "storeFilterAll": "Semua", - "storeFilterMetadata": "Metadata", - "storeFilterDownload": "Unduhan", - "storeFilterUtility": "Utilitas", - "storeFilterLyrics": "Lirik", - "storeFilterIntegration": "Integrasi", - "storeClearFilters": "Hapus filter", - "storeNoResults": "Tidak ada ekstensi ditemukan", - - "extensionProviderPriority": "Prioritas Provider", - "extensionInstallButton": "Pasang Ekstensi", - "extensionDefaultProvider": "Default (Deezer/Spotify)", - "extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan", - "extensionAuthor": "Pembuat", - "extensionId": "ID", - "extensionError": "Error", - "extensionCapabilities": "Kemampuan", - "extensionMetadataProvider": "Provider Metadata", - "extensionDownloadProvider": "Provider Unduhan", - "extensionLyricsProvider": "Provider Lirik", - "extensionUrlHandler": "Penanganan URL", - "extensionQualityOptions": "Opsi Kualitas", - "extensionPostProcessingHooks": "Hook Pasca-Pemrosesan", - "extensionPermissions": "Izin", - "extensionSettings": "Pengaturan", - "extensionRemoveButton": "Hapus Ekstensi", - "extensionUpdated": "Diperbarui", - "extensionMinAppVersion": "Versi App Minimum", - - "qualityFlacLossless": "FLAC Lossless", - "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", - "qualityHiResFlac": "Hi-Res FLAC", - "qualityHiResFlacSubtitle": "24-bit / hingga 96kHz", - "qualityHiResFlacMax": "Hi-Res FLAC Max", - "qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz", - "qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan", - - "downloadAskBeforeDownload": "Tanya Sebelum Unduh", - "downloadDirectory": "Direktori Unduhan", - "downloadSeparateSinglesFolder": "Folder Singles Terpisah", - "downloadAlbumFolderStructure": "Struktur Folder Album", - "downloadSaveFormat": "Simpan Format", - "downloadSelectService": "Pilih Layanan", - "downloadSelectQuality": "Pilih Kualitas", - "downloadFrom": "Unduh Dari", - "downloadDefaultQualityLabel": "Kualitas Default", - "downloadBestAvailable": "Terbaik tersedia", - - "folderNone": "Tidak ada", - "folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan", - "folderArtist": "Artis", - "folderArtistSubtitle": "Nama Artis/namafile", - "folderAlbum": "Album", - "folderAlbumSubtitle": "Nama Album/namafile", - "folderArtistAlbum": "Artis/Album", - "folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile", - - "serviceTidal": "Tidal", - "serviceQobuz": "Qobuz", - "serviceAmazon": "Amazon", - "serviceDeezer": "Deezer", - "serviceSpotify": "Spotify", - - "logSearchHint": "Cari log...", - "logFilterLevel": "Level", - "logFilterSection": "Filter", - "logShareLogs": "Bagikan log", - "logClearLogs": "Hapus log", - "logClearLogsTitle": "Hapus Log", - "logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?", - "logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI", - "logRateLimited": "DIBATASI", - "logNetworkError": "ERROR JARINGAN", - "logTrackNotFound": "LAGU TIDAK DITEMUKAN", - - "appearanceAmoledDark": "AMOLED Gelap", - "appearanceAmoledDarkSubtitle": "Latar belakang hitam murni", - "appearanceChooseAccentColor": "Pilih Warna Aksen", - "appearanceChooseTheme": "Mode Tema", - - "updateStartingDownload": "Memulai unduhan...", - "updateDownloadFailed": "Unduhan gagal", - "updateFailedMessage": "Gagal mengunduh pembaruan", - "updateNewVersionReady": "Versi baru sudah siap", - "updateCurrent": "Saat ini", - "updateNew": "Baru", - "updateDownloading": "Mengunduh...", - "updateWhatsNew": "Yang Baru", - "updateDownloadInstall": "Unduh & Pasang", - "updateDontRemind": "Jangan ingatkan", - - "trackCopyFilePath": "Salin lokasi file", - "trackRemoveFromDevice": "Hapus dari perangkat", - "trackLoadLyrics": "Muat Lirik", - - "dateToday": "Hari ini", - "dateYesterday": "Kemarin", - "dateDaysAgo": "{count} hari lalu", - "dateWeeksAgo": "{count} minggu lalu", - "dateMonthsAgo": "{count} bulan lalu", - - "concurrentSequential": "Berurutan", - "concurrentParallel2": "2 Paralel", - "concurrentParallel3": "3 Paralel", - - "filenameAvailablePlaceholders": "Placeholder yang tersedia:", - "filenameHint": "{artist} - {title}", - - "tapToSeeError": "Ketuk untuk melihat detail error", - - "setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.", - "setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.", - "setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.", - "setupSkipForNow": "Lewati untuk sekarang", - "setupBack": "Kembali", - "setupNext": "Lanjut", - "setupGetStarted": "Mulai", - "setupSkipAndStart": "Lewati & Mulai", - "setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.", - "setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com", - - "trackMetadata": "Metadata", - "trackFileInfo": "Info File", - "trackLyrics": "Lirik", - "trackFileNotFound": "File tidak ditemukan", - "trackOpenInDeezer": "Buka di Deezer", - "trackOpenInSpotify": "Buka di Spotify", - "trackTrackName": "Nama lagu", - "trackArtist": "Artis", - "trackAlbumArtist": "Artis album", - "trackAlbum": "Album", - "trackTrackNumber": "Nomor lagu", - "trackDiscNumber": "Nomor disc", - "trackDuration": "Durasi", - "trackAudioQuality": "Kualitas audio", - "trackReleaseDate": "Tanggal rilis", - "trackDownloaded": "Diunduh", - "trackCopyLyrics": "Salin lirik", - "trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini", - "trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.", - "trackLyricsLoadFailed": "Gagal memuat lirik", - "trackCopiedToClipboard": "Disalin ke clipboard", - "trackDeleteConfirmTitle": "Hapus dari perangkat?", - "trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.", - "trackCannotOpen": "Tidak dapat membuka: {message}", - - "logFilterBySeverity": "Filter log berdasarkan tingkat keparahan", - "logNoLogsYet": "Belum ada log", - "logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi", - "logIssueSummary": "Ringkasan Masalah", - "logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan", - "logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8", - "logRateLimitedDescription": "Terlalu banyak permintaan ke layanan", - "logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi", - "logNetworkErrorDescription": "Masalah koneksi terdeteksi", - "logNetworkErrorSuggestion": "Periksa koneksi internet Anda", - "logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan", - "logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless", - "logTotalErrors": "Total error: {count}", - "logAffected": "Terpengaruh: {domains}", - "logEntriesFiltered": "Entri ({count} difilter)", - "logEntries": "Entri ({count})", - - "extensionsProviderPrioritySection": "Prioritas Provider", - "extensionsInstalledSection": "Ekstensi Terpasang", - "extensionsNoExtensions": "Tidak ada ekstensi terpasang", - "extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru", - "extensionsInstallButton": "Pasang Ekstensi", - "extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.", - "extensionsInstalledSuccess": "Ekstensi berhasil dipasang", - "extensionsDownloadPriority": "Prioritas Unduhan", - "extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan", - "extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan", - "extensionsMetadataPriority": "Prioritas Metadata", - "extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata", - "extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata", - "extensionsSearchProvider": "Provider Pencarian", - "extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom", - "extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu", - "extensionsCustomSearch": "Pencarian kustom", - "extensionsErrorLoading": "Error memuat ekstensi", - - "extensionCustomTrackMatching": "Pencocokan Lagu Kustom", - "extensionPostProcessing": "Pasca-Pemrosesan", - "extensionHooksAvailable": "{count} hook tersedia", - "extensionPatternsCount": "{count} pola", - "extensionStrategy": "Strategi: {strategy}", - - "aboutDoubleDouble": "DoubleDouble", - "aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!", - "aboutDabMusic": "DAB Music", - "aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!", - - "queueTitle": "Antrian Unduhan", - "queueClearAll": "Hapus Semua", - "queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?", - - "albumFolderArtistAlbum": "Artis / Album", - "albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/", - "albumFolderArtistYearAlbum": "Artis / [Tahun] Album", - "albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/", - "albumFolderAlbumOnly": "Album Saja", - "albumFolderAlbumOnlySubtitle": "Albums/Nama Album/", - "albumFolderYearAlbum": "[Tahun] Album", - "albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/", - - "downloadedAlbumDeleteSelected": "Hapus yang Dipilih", - "downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.", - - "utilityFunctions": "Fungsi Utilitas", - - "aboutMobileDeveloper": "Pengembang versi mobile", + "@aboutMobileDeveloper": { + "description": "Role description for mobile dev" + }, "aboutOriginalCreator": "Pembuat SpotiFLAC asli", + "@aboutOriginalCreator": { + "description": "Role description for original creator" + }, "aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!", - "aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!", - "aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!", + "@aboutLogoArtist": { + "description": "Role description for logo artist" + }, + "aboutSpecialThanks": "Terima Kasih Khusus", + "@aboutSpecialThanks": { + "description": "Section for special thanks" + }, + "aboutLinks": "Tautan", + "@aboutLinks": { + "description": "Section for external links" + }, "aboutMobileSource": "Kode sumber mobile", + "@aboutMobileSource": { + "description": "Link to mobile GitHub repo" + }, "aboutPCSource": "Kode sumber PC", + "@aboutPCSource": { + "description": "Link to PC GitHub repo" + }, "aboutReportIssue": "Laporkan masalah", + "@aboutReportIssue": { + "description": "Link to report bugs" + }, "aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui", + "@aboutReportIssueSubtitle": { + "description": "Subtitle for report issue" + }, "aboutFeatureRequest": "Permintaan fitur", + "@aboutFeatureRequest": { + "description": "Link to suggest features" + }, "aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi", + "@aboutFeatureRequestSubtitle": { + "description": "Subtitle for feature request" + }, + "aboutSupport": "Dukungan", + "@aboutSupport": { + "description": "Section for support/donation links" + }, "aboutBuyMeCoffee": "Belikan saya kopi", + "@aboutBuyMeCoffee": { + "description": "Donation link" + }, "aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi", + "@aboutBuyMeCoffeeSubtitle": { + "description": "Subtitle for donation" + }, + "aboutApp": "Aplikasi", + "@aboutApp": { + "description": "Section for app info" + }, "aboutVersion": "Versi", + "@aboutVersion": { + "description": "Version info label" + }, + "aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!", + "@aboutBinimumDesc": { + "description": "Credit description for binimum" + }, + "aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!", + "@aboutSachinsenalDesc": { + "description": "Credit description for sachinsenal0x64" + }, + "aboutDoubleDouble": "DoubleDouble", + "@aboutDoubleDouble": { + "description": "Name of Amazon API service - DO NOT TRANSLATE" + }, + "aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!", + "@aboutDoubleDoubleDesc": { + "description": "Credit for DoubleDouble API" + }, + "aboutDabMusic": "DAB Music", + "@aboutDabMusic": { + "description": "Name of Qobuz API service - DO NOT TRANSLATE" + }, + "aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!", + "@aboutDabMusicDesc": { + "description": "Credit for DAB Music API" + }, "aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", - - "providerPriorityTitle": "Prioritas Provider", - "providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.", - "providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.", - "providerBuiltIn": "Bawaan", - "providerExtension": "Ekstensi", - - "metadataProviderPriorityTitle": "Prioritas Metadata", - "metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.", - "metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.", - "metadataNoRateLimits": "Tidak ada batas rate", - "metadataMayRateLimit": "Mungkin dibatasi rate", - - "queueEmpty": "Tidak ada unduhan dalam antrian", - "queueEmptySubtitle": "Tambahkan lagu dari layar beranda", - "queueClearCompleted": "Hapus yang selesai", - "queueDownloadFailed": "Unduhan Gagal", - "queueTrackLabel": "Lagu:", - "queueArtistLabel": "Artis:", - "queueErrorLabel": "Error:", - "queueUnknownError": "Error tidak diketahui", - - "downloadedAlbumTracksHeader": "Lagu", - "downloadedAlbumDownloadedCount": "{count} diunduh", - "downloadedAlbumSelectedCount": "{count} dipilih", - "downloadedAlbumAllSelected": "Semua lagu dipilih", - "downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih", - "downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", - "downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus", - - "folderOrganizationDescription": "Atur file yang diunduh ke dalam folder", + "@aboutAppDescription": { + "description": "App description in header card" + }, + "albumTitle": "Album", + "@albumTitle": { + "description": "Album screen title" + }, + "albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "@albumTracks": { + "description": "Album track count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "albumDownloadAll": "Unduh Semua", + "@albumDownloadAll": { + "description": "Button to download all tracks" + }, + "albumDownloadRemaining": "Unduh Sisanya", + "@albumDownloadRemaining": { + "description": "Button to download remaining tracks" + }, + "playlistTitle": "Playlist", + "@playlistTitle": { + "description": "Playlist screen title" + }, + "artistTitle": "Artis", + "@artistTitle": { + "description": "Artist screen title" + }, + "artistAlbums": "Album", + "@artistAlbums": { + "description": "Section header for artist albums" + }, + "artistSingles": "Single & EP", + "@artistSingles": { + "description": "Section header for singles/EPs" + }, + "artistCompilations": "Kompilasi", + "@artistCompilations": { + "description": "Section header for compilations" + }, + "artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}", + "@artistReleases": { + "description": "Artist release count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "artistPopular": "Populer", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} pendengar bulanan", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, + "trackMetadataTitle": "Info Lagu", + "@trackMetadataTitle": { + "description": "Track metadata screen title" + }, + "trackMetadataArtist": "Artis", + "@trackMetadataArtist": { + "description": "Metadata field - artist name" + }, + "trackMetadataAlbum": "Album", + "@trackMetadataAlbum": { + "description": "Metadata field - album name" + }, + "trackMetadataDuration": "Durasi", + "@trackMetadataDuration": { + "description": "Metadata field - track length" + }, + "trackMetadataQuality": "Kualitas", + "@trackMetadataQuality": { + "description": "Metadata field - audio quality" + }, + "trackMetadataPath": "Lokasi File", + "@trackMetadataPath": { + "description": "Metadata field - file location" + }, + "trackMetadataDownloadedAt": "Diunduh", + "@trackMetadataDownloadedAt": { + "description": "Metadata field - download date" + }, + "trackMetadataService": "Layanan", + "@trackMetadataService": { + "description": "Metadata field - download service used" + }, + "trackMetadataPlay": "Putar", + "@trackMetadataPlay": { + "description": "Action button - play track" + }, + "trackMetadataShare": "Bagikan", + "@trackMetadataShare": { + "description": "Action button - share track" + }, + "trackMetadataDelete": "Hapus", + "@trackMetadataDelete": { + "description": "Action button - delete track" + }, + "trackMetadataRedownload": "Unduh ulang", + "@trackMetadataRedownload": { + "description": "Action button - download again" + }, + "trackMetadataOpenFolder": "Buka Folder", + "@trackMetadataOpenFolder": { + "description": "Action button - open containing folder" + }, + "setupTitle": "Selamat Datang di SpotiFLAC", + "@setupTitle": { + "description": "Setup wizard title" + }, + "setupSubtitle": "Mari mulai pengaturan", + "@setupSubtitle": { + "description": "Setup wizard subtitle" + }, + "setupStoragePermission": "Izin Penyimpanan", + "@setupStoragePermission": { + "description": "Storage permission step title" + }, + "setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan", + "@setupStoragePermissionSubtitle": { + "description": "Explanation for storage permission" + }, + "setupStoragePermissionGranted": "Izin diberikan", + "@setupStoragePermissionGranted": { + "description": "Status when permission granted" + }, + "setupStoragePermissionDenied": "Izin ditolak", + "@setupStoragePermissionDenied": { + "description": "Status when permission denied" + }, + "setupGrantPermission": "Berikan Izin", + "@setupGrantPermission": { + "description": "Button to request permission" + }, + "setupDownloadLocation": "Lokasi Unduhan", + "@setupDownloadLocation": { + "description": "Download folder step title" + }, + "setupChooseFolder": "Pilih Folder", + "@setupChooseFolder": { + "description": "Button to pick folder" + }, + "setupContinue": "Lanjutkan", + "@setupContinue": { + "description": "Continue to next step button" + }, + "setupSkip": "Lewati untuk sekarang", + "@setupSkip": { + "description": "Skip current step button" + }, + "setupStorageAccessRequired": "Akses Penyimpanan Diperlukan", + "@setupStorageAccessRequired": { + "description": "Title when storage access needed" + }, + "setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.", + "@setupStorageAccessMessage": { + "description": "Explanation for storage access" + }, + "setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.", + "@setupStorageAccessMessageAndroid11": { + "description": "Android 11+ specific explanation" + }, + "setupOpenSettings": "Buka Pengaturan", + "@setupOpenSettings": { + "description": "Button to open system settings" + }, + "setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.", + "@setupPermissionDeniedMessage": { + "description": "Error when permission denied" + }, + "setupPermissionRequired": "Izin {permissionType} Diperlukan", + "@setupPermissionRequired": { + "description": "Generic permission required title", + "placeholders": { + "permissionType": { + "type": "String", + "description": "Type of permission (Storage/Notification)" + } + } + }, + "setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.", + "@setupPermissionRequiredMessage": { + "description": "Generic permission required message", + "placeholders": { + "permissionType": { + "type": "String" + } + } + }, + "setupSelectDownloadFolder": "Pilih Folder Unduhan", + "@setupSelectDownloadFolder": { + "description": "Folder selection step title" + }, + "setupUseDefaultFolder": "Gunakan Folder Default?", + "@setupUseDefaultFolder": { + "description": "Dialog title for default folder" + }, + "setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?", + "@setupNoFolderSelected": { + "description": "Prompt when no folder selected" + }, + "setupUseDefault": "Gunakan Default", + "@setupUseDefault": { + "description": "Button to use default folder" + }, + "setupDownloadLocationTitle": "Lokasi Unduhan", + "@setupDownloadLocationTitle": { + "description": "Download location dialog title" + }, + "setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.", + "@setupDownloadLocationIosMessage": { + "description": "iOS-specific folder info" + }, + "setupAppDocumentsFolder": "Folder Documents Aplikasi", + "@setupAppDocumentsFolder": { + "description": "iOS documents folder option" + }, + "setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files", + "@setupAppDocumentsFolderSubtitle": { + "description": "Subtitle for documents folder" + }, + "setupChooseFromFiles": "Pilih dari Files", + "@setupChooseFromFiles": { + "description": "iOS file picker option" + }, + "setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya", + "@setupChooseFromFilesSubtitle": { + "description": "Subtitle for file picker" + }, + "setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.", + "@setupIosEmptyFolderWarning": { + "description": "iOS folder selection warning" + }, + "setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC", + "@setupDownloadInFlac": { + "description": "App tagline in setup" + }, + "setupStepStorage": "Penyimpanan", + "@setupStepStorage": { + "description": "Setup step indicator - storage" + }, + "setupStepNotification": "Notifikasi", + "@setupStepNotification": { + "description": "Setup step indicator - notification" + }, + "setupStepFolder": "Folder", + "@setupStepFolder": { + "description": "Setup step indicator - folder" + }, + "setupStepSpotify": "Spotify", + "@setupStepSpotify": { + "description": "Setup step indicator - Spotify API" + }, + "setupStepPermission": "Izin", + "@setupStepPermission": { + "description": "Setup step indicator - permission" + }, + "setupStorageGranted": "Izin Penyimpanan Diberikan!", + "@setupStorageGranted": { + "description": "Success message for storage permission" + }, + "setupStorageRequired": "Izin Penyimpanan Diperlukan", + "@setupStorageRequired": { + "description": "Title when storage permission needed" + }, + "setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.", + "@setupStorageDescription": { + "description": "Explanation for storage permission" + }, + "setupNotificationGranted": "Izin Notifikasi Diberikan!", + "@setupNotificationGranted": { + "description": "Success message for notification permission" + }, + "setupNotificationEnable": "Aktifkan Notifikasi", + "@setupNotificationEnable": { + "description": "Button to enable notifications" + }, + "setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.", + "@setupNotificationDescription": { + "description": "Explanation for notifications" + }, + "setupFolderSelected": "Folder Unduhan Dipilih!", + "@setupFolderSelected": { + "description": "Success message for folder selection" + }, + "setupFolderChoose": "Pilih Folder Unduhan", + "@setupFolderChoose": { + "description": "Button to choose folder" + }, + "setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.", + "@setupFolderDescription": { + "description": "Explanation for folder selection" + }, + "setupChangeFolder": "Ubah Folder", + "@setupChangeFolder": { + "description": "Button to change selected folder" + }, + "setupSelectFolder": "Pilih Folder", + "@setupSelectFolder": { + "description": "Button to select folder" + }, + "setupSpotifyApiOptional": "Spotify API (Opsional)", + "@setupSpotifyApiOptional": { + "description": "Spotify API step title" + }, + "setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.", + "@setupSpotifyApiDescription": { + "description": "Explanation for Spotify API" + }, + "setupUseSpotifyApi": "Gunakan Spotify API", + "@setupUseSpotifyApi": { + "description": "Toggle to enable Spotify API" + }, + "setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah", + "@setupEnterCredentialsBelow": { + "description": "Prompt to enter credentials" + }, + "setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)", + "@setupUsingDeezer": { + "description": "Status when using Deezer" + }, + "setupEnterClientId": "Masukkan Spotify Client ID", + "@setupEnterClientId": { + "description": "Placeholder for client ID field" + }, + "setupEnterClientSecret": "Masukkan Spotify Client Secret", + "@setupEnterClientSecret": { + "description": "Placeholder for client secret field" + }, + "setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.", + "@setupGetFreeCredentials": { + "description": "Info about getting Spotify credentials" + }, + "setupEnableNotifications": "Aktifkan Notifikasi", + "@setupEnableNotifications": { + "description": "Button to enable notifications" + }, + "setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.", + "@setupProceedToNextStep": { + "description": "Message after completing a step" + }, + "setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.", + "@setupNotificationProgressDescription": { + "description": "Info about notification usage" + }, + "setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.", + "@setupNotificationBackgroundDescription": { + "description": "Detailed notification explanation" + }, + "setupSkipForNow": "Lewati untuk sekarang", + "@setupSkipForNow": { + "description": "Skip button text" + }, + "setupBack": "Kembali", + "@setupBack": { + "description": "Back button text" + }, + "setupNext": "Lanjut", + "@setupNext": { + "description": "Next button text" + }, + "setupGetStarted": "Mulai", + "@setupGetStarted": { + "description": "Final setup button" + }, + "setupSkipAndStart": "Lewati & Mulai", + "@setupSkipAndStart": { + "description": "Skip setup and start app" + }, + "setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.", + "@setupAllowAccessToManageFiles": { + "description": "Instruction for file access permission" + }, + "setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com", + "@setupGetCredentialsFromSpotify": { + "description": "Link text for Spotify developer portal" + }, + "dialogCancel": "Batal", + "@dialogCancel": { + "description": "Dialog button - cancel action" + }, + "dialogOk": "OK", + "@dialogOk": { + "description": "Dialog button - confirm/acknowledge" + }, + "dialogSave": "Simpan", + "@dialogSave": { + "description": "Dialog button - save changes" + }, + "dialogDelete": "Hapus", + "@dialogDelete": { + "description": "Dialog button - delete item" + }, + "dialogRetry": "Coba Lagi", + "@dialogRetry": { + "description": "Dialog button - retry action" + }, + "dialogClose": "Tutup", + "@dialogClose": { + "description": "Dialog button - close dialog" + }, + "dialogYes": "Ya", + "@dialogYes": { + "description": "Dialog button - confirm yes" + }, + "dialogNo": "Tidak", + "@dialogNo": { + "description": "Dialog button - confirm no" + }, + "dialogClear": "Hapus", + "@dialogClear": { + "description": "Dialog button - clear items" + }, + "dialogConfirm": "Konfirmasi", + "@dialogConfirm": { + "description": "Dialog button - confirm action" + }, + "dialogDone": "Selesai", + "@dialogDone": { + "description": "Dialog button - action completed" + }, + "dialogImport": "Impor", + "@dialogImport": { + "description": "Dialog button - import data" + }, + "dialogDiscard": "Buang", + "@dialogDiscard": { + "description": "Dialog button - discard changes" + }, + "dialogRemove": "Hapus", + "@dialogRemove": { + "description": "Dialog button - remove item" + }, + "dialogUninstall": "Copot", + "@dialogUninstall": { + "description": "Dialog button - uninstall extension" + }, + "dialogDiscardChanges": "Buang Perubahan?", + "@dialogDiscardChanges": { + "description": "Dialog title - unsaved changes warning" + }, + "dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?", + "@dialogUnsavedChanges": { + "description": "Dialog message - unsaved changes" + }, + "dialogDownloadFailed": "Unduhan Gagal", + "@dialogDownloadFailed": { + "description": "Dialog title - download error" + }, + "dialogTrackLabel": "Lagu:", + "@dialogTrackLabel": { + "description": "Label for track name in error dialog" + }, + "dialogArtistLabel": "Artis:", + "@dialogArtistLabel": { + "description": "Label for artist name in error dialog" + }, + "dialogErrorLabel": "Error:", + "@dialogErrorLabel": { + "description": "Label for error message" + }, + "dialogClearAll": "Hapus Semua", + "@dialogClearAll": { + "description": "Dialog title - clear all items" + }, + "dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?", + "@dialogClearAllDownloads": { + "description": "Dialog message - clear downloads confirmation" + }, + "dialogRemoveFromDevice": "Hapus dari perangkat?", + "@dialogRemoveFromDevice": { + "description": "Dialog title - delete file confirmation" + }, + "dialogRemoveExtension": "Hapus Ekstensi", + "@dialogRemoveExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.", + "@dialogRemoveExtensionMessage": { + "description": "Dialog message - uninstall confirmation" + }, + "dialogUninstallExtension": "Copot Ekstensi?", + "@dialogUninstallExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?", + "@dialogUninstallExtensionMessage": { + "description": "Dialog message - uninstall specific extension", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "dialogClearHistoryTitle": "Hapus Riwayat", + "@dialogClearHistoryTitle": { + "description": "Dialog title - clear download history" + }, + "dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.", + "@dialogClearHistoryMessage": { + "description": "Dialog message - clear history confirmation" + }, + "dialogDeleteSelectedTitle": "Hapus yang Dipilih", + "@dialogDeleteSelectedTitle": { + "description": "Dialog title - delete selected items" + }, + "dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.", + "@dialogDeleteSelectedMessage": { + "description": "Dialog message - delete selected tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dialogImportPlaylistTitle": "Impor Playlist", + "@dialogImportPlaylistTitle": { + "description": "Dialog title - import CSV playlist" + }, + "dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?", + "@dialogImportPlaylistMessage": { + "description": "Dialog message - import playlist confirmation", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian", + "@snackbarAddedToQueue": { + "description": "Snackbar - track added to download queue", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian", + "@snackbarAddedTracksToQueue": { + "description": "Snackbar - multiple tracks added to queue", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh", + "@snackbarAlreadyDownloaded": { + "description": "Snackbar - track already exists", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarHistoryCleared": "Riwayat dihapus", + "@snackbarHistoryCleared": { + "description": "Snackbar - history deleted" + }, + "snackbarCredentialsSaved": "Kredensial disimpan", + "@snackbarCredentialsSaved": { + "description": "Snackbar - Spotify credentials saved" + }, + "snackbarCredentialsCleared": "Kredensial dihapus", + "@snackbarCredentialsCleared": { + "description": "Snackbar - Spotify credentials removed" + }, + "snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}", + "@snackbarDeletedTracks": { + "description": "Snackbar - tracks deleted", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarCannotOpenFile": "Tidak dapat membuka file: {error}", + "@snackbarCannotOpenFile": { + "description": "Snackbar - file open error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarFillAllFields": "Harap isi semua field", + "@snackbarFillAllFields": { + "description": "Snackbar - validation error" + }, + "snackbarViewQueue": "Lihat Antrian", + "@snackbarViewQueue": { + "description": "Snackbar action - view download queue" + }, + "snackbarFailedToLoad": "Gagal memuat: {error}", + "@snackbarFailedToLoad": { + "description": "Snackbar - loading error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarUrlCopied": "URL {platform} disalin ke clipboard", + "@snackbarUrlCopied": { + "description": "Snackbar - URL copied", + "placeholders": { + "platform": { + "type": "String", + "description": "Platform name (Spotify/Deezer)" + } + } + }, + "snackbarFileNotFound": "File tidak ditemukan", + "@snackbarFileNotFound": { + "description": "Snackbar - file doesn't exist" + }, + "snackbarSelectExtFile": "Harap pilih file .spotiflac-ext", + "@snackbarSelectExtFile": { + "description": "Snackbar - wrong file type selected" + }, + "snackbarProviderPrioritySaved": "Prioritas provider disimpan", + "@snackbarProviderPrioritySaved": { + "description": "Snackbar - provider order saved" + }, + "snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan", + "@snackbarMetadataProviderSaved": { + "description": "Snackbar - metadata provider order saved" + }, + "snackbarExtensionInstalled": "{extensionName} terpasang.", + "@snackbarExtensionInstalled": { + "description": "Snackbar - extension installed successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarExtensionUpdated": "{extensionName} diperbarui.", + "@snackbarExtensionUpdated": { + "description": "Snackbar - extension updated successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarFailedToInstall": "Gagal memasang ekstensi", + "@snackbarFailedToInstall": { + "description": "Snackbar - extension install error" + }, + "snackbarFailedToUpdate": "Gagal memperbarui ekstensi", + "@snackbarFailedToUpdate": { + "description": "Snackbar - extension update error" + }, + "errorRateLimited": "Dibatasi", + "@errorRateLimited": { + "description": "Error title - too many requests" + }, + "errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.", + "@errorRateLimitedMessage": { + "description": "Error message - rate limit explanation" + }, + "errorFailedToLoad": "Gagal memuat {item}", + "@errorFailedToLoad": { + "description": "Error message - loading failed", + "placeholders": { + "item": { + "type": "String", + "description": "Item that failed to load (album/playlist/etc)" + } + } + }, + "errorNoTracksFound": "Tidak ada lagu ditemukan", + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, + "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", + "@errorMissingExtensionSource": { + "description": "Error - extension source not available", + "placeholders": { + "item": { + "type": "String" + } + } + }, + "statusQueued": "Mengantri", + "@statusQueued": { + "description": "Download status - waiting in queue" + }, + "statusDownloading": "Mengunduh", + "@statusDownloading": { + "description": "Download status - in progress" + }, + "statusFinalizing": "Menyelesaikan", + "@statusFinalizing": { + "description": "Download status - writing metadata" + }, + "statusCompleted": "Selesai", + "@statusCompleted": { + "description": "Download status - finished" + }, + "statusFailed": "Gagal", + "@statusFailed": { + "description": "Download status - error occurred" + }, + "statusSkipped": "Dilewati", + "@statusSkipped": { + "description": "Download status - already exists" + }, + "statusPaused": "Dijeda", + "@statusPaused": { + "description": "Download status - paused" + }, + "actionPause": "Jeda", + "@actionPause": { + "description": "Action button - pause download" + }, + "actionResume": "Lanjutkan", + "@actionResume": { + "description": "Action button - resume download" + }, + "actionCancel": "Batal", + "@actionCancel": { + "description": "Action button - cancel operation" + }, + "actionStop": "Hentikan", + "@actionStop": { + "description": "Action button - stop operation" + }, + "actionSelect": "Pilih", + "@actionSelect": { + "description": "Action button - enter selection mode" + }, + "actionSelectAll": "Pilih Semua", + "@actionSelectAll": { + "description": "Action button - select all items" + }, + "actionDeselect": "Batal Pilih", + "@actionDeselect": { + "description": "Action button - deselect all" + }, + "actionPaste": "Tempel", + "@actionPaste": { + "description": "Action button - paste from clipboard" + }, + "actionImportCsv": "Impor CSV", + "@actionImportCsv": { + "description": "Action button - import CSV file" + }, + "actionRemoveCredentials": "Hapus Kredensial", + "@actionRemoveCredentials": { + "description": "Action button - delete Spotify credentials" + }, + "actionSaveCredentials": "Simpan Kredensial", + "@actionSaveCredentials": { + "description": "Action button - save Spotify credentials" + }, + "selectionSelected": "{count} dipilih", + "@selectionSelected": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionAllSelected": "Semua lagu dipilih", + "@selectionAllSelected": { + "description": "Status - all items selected" + }, + "selectionTapToSelect": "Ketuk lagu untuk memilih", + "@selectionTapToSelect": { + "description": "Hint - how to select items" + }, + "selectionDeleteTracks": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", + "@selectionDeleteTracks": { + "description": "Delete button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionSelectToDelete": "Pilih lagu untuk dihapus", + "@selectionSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "progressFetchingMetadata": "Mengambil metadata... {current}/{total}", + "@progressFetchingMetadata": { + "description": "Progress indicator - loading track info", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "progressReadingCsv": "Membaca CSV...", + "@progressReadingCsv": { + "description": "Progress indicator - parsing CSV file" + }, + "searchSongs": "Lagu", + "@searchSongs": { + "description": "Search result category - songs" + }, + "searchArtists": "Artis", + "@searchArtists": { + "description": "Search result category - artists" + }, + "searchAlbums": "Album", + "@searchAlbums": { + "description": "Search result category - albums" + }, + "searchPlaylists": "Playlist", + "@searchPlaylists": { + "description": "Search result category - playlists" + }, + "tooltipPlay": "Putar", + "@tooltipPlay": { + "description": "Tooltip - play button" + }, + "tooltipCancel": "Batal", + "@tooltipCancel": { + "description": "Tooltip - cancel button" + }, + "tooltipStop": "Hentikan", + "@tooltipStop": { + "description": "Tooltip - stop button" + }, + "tooltipRetry": "Coba Lagi", + "@tooltipRetry": { + "description": "Tooltip - retry button" + }, + "tooltipRemove": "Hapus", + "@tooltipRemove": { + "description": "Tooltip - remove button" + }, + "tooltipClear": "Hapus", + "@tooltipClear": { + "description": "Tooltip - clear button" + }, + "tooltipPaste": "Tempel", + "@tooltipPaste": { + "description": "Tooltip - paste button" + }, + "filenameFormat": "Format Nama File", + "@filenameFormat": { + "description": "Setting title - filename pattern" + }, + "filenameFormatPreview": "Pratinjau: {preview}", + "@filenameFormatPreview": { + "description": "Preview of filename pattern", + "placeholders": { + "preview": { + "type": "String" + } + } + }, + "filenameAvailablePlaceholders": "Placeholder yang tersedia:", + "@filenameAvailablePlaceholders": { + "description": "Label for placeholder list" + }, + "filenameHint": "{artist} - {title}", + "@filenameHint": { + "description": "Default filename format hint" + }, + "folderOrganization": "Organisasi Folder", + "@folderOrganization": { + "description": "Setting title - folder structure" + }, "folderOrganizationNone": "Tidak ada", - "folderOrganizationNoneSubtitle": "Semua file di folder unduhan", + "@folderOrganizationNone": { + "description": "Folder option - flat structure" + }, "folderOrganizationByArtist": "Berdasarkan Artis", - "folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis", + "@folderOrganizationByArtist": { + "description": "Folder option - artist folders" + }, "folderOrganizationByAlbum": "Berdasarkan Album", - "folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album", + "@folderOrganizationByAlbum": { + "description": "Folder option - album folders" + }, "folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album", + "@folderOrganizationByArtistAlbum": { + "description": "Folder option - nested folders" + }, + "folderOrganizationDescription": "Atur file yang diunduh ke dalam folder", + "@folderOrganizationDescription": { + "description": "Folder organization sheet description" + }, + "folderOrganizationNoneSubtitle": "Semua file di folder unduhan", + "@folderOrganizationNoneSubtitle": { + "description": "Subtitle for no organization option" + }, + "folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis", + "@folderOrganizationByArtistSubtitle": { + "description": "Subtitle for artist folder option" + }, + "folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album", + "@folderOrganizationByAlbumSubtitle": { + "description": "Subtitle for album folder option" + }, "folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album", - + "@folderOrganizationByArtistAlbumSubtitle": { + "description": "Subtitle for nested folder option" + }, + "updateAvailable": "Pembaruan Tersedia", + "@updateAvailable": { + "description": "Update dialog title" + }, + "updateNewVersion": "Versi {version} tersedia", + "@updateNewVersion": { + "description": "Update available message", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "updateDownload": "Unduh", + "@updateDownload": { + "description": "Update button - download update" + }, + "updateLater": "Nanti", + "@updateLater": { + "description": "Update button - dismiss" + }, + "updateChangelog": "Log Perubahan", + "@updateChangelog": { + "description": "Link to changelog" + }, + "updateStartingDownload": "Memulai unduhan...", + "@updateStartingDownload": { + "description": "Update status - initializing" + }, + "updateDownloadFailed": "Unduhan gagal", + "@updateDownloadFailed": { + "description": "Update error title" + }, + "updateFailedMessage": "Gagal mengunduh pembaruan", + "@updateFailedMessage": { + "description": "Update error message" + }, + "updateNewVersionReady": "Versi baru sudah siap", + "@updateNewVersionReady": { + "description": "Update subtitle" + }, + "updateCurrent": "Saat ini", + "@updateCurrent": { + "description": "Label for current version" + }, + "updateNew": "Baru", + "@updateNew": { + "description": "Label for new version" + }, + "updateDownloading": "Mengunduh...", + "@updateDownloading": { + "description": "Update status - downloading" + }, + "updateWhatsNew": "Yang Baru", + "@updateWhatsNew": { + "description": "Changelog section title" + }, + "updateDownloadInstall": "Unduh & Pasang", + "@updateDownloadInstall": { + "description": "Update button - download and install" + }, + "updateDontRemind": "Jangan ingatkan", + "@updateDontRemind": { + "description": "Update button - skip this version" + }, + "providerPriority": "Prioritas Provider", + "@providerPriority": { + "description": "Setting title - download provider order" + }, + "providerPrioritySubtitle": "Seret untuk mengatur ulang provider unduhan", + "@providerPrioritySubtitle": { + "description": "Subtitle for provider priority" + }, + "providerPriorityTitle": "Prioritas Provider", + "@providerPriorityTitle": { + "description": "Provider priority page title" + }, + "providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.", + "@providerPriorityDescription": { + "description": "Provider priority page description" + }, + "providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.", + "@providerPriorityInfo": { + "description": "Info tip about fallback behavior" + }, + "providerBuiltIn": "Bawaan", + "@providerBuiltIn": { + "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + }, + "providerExtension": "Ekstensi", + "@providerExtension": { + "description": "Label for extension-provided providers" + }, + "metadataProviderPriority": "Prioritas Provider Metadata", + "@metadataProviderPriority": { + "description": "Setting title - metadata provider order" + }, + "metadataProviderPrioritySubtitle": "Urutan yang digunakan saat mengambil metadata lagu", + "@metadataProviderPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "metadataProviderPriorityTitle": "Prioritas Metadata", + "@metadataProviderPriorityTitle": { + "description": "Metadata priority page title" + }, + "metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.", + "@metadataProviderPriorityDescription": { + "description": "Metadata priority page description" + }, + "metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.", + "@metadataProviderPriorityInfo": { + "description": "Info tip about rate limits" + }, + "metadataNoRateLimits": "Tidak ada batas rate", + "@metadataNoRateLimits": { + "description": "Deezer provider description" + }, + "metadataMayRateLimit": "Mungkin dibatasi rate", + "@metadataMayRateLimit": { + "description": "Spotify provider description" + }, + "logTitle": "Log", + "@logTitle": { + "description": "Logs screen title" + }, + "logCopy": "Salin Log", + "@logCopy": { + "description": "Action - copy logs to clipboard" + }, + "logClear": "Hapus Log", + "@logClear": { + "description": "Action - delete all logs" + }, + "logShare": "Bagikan Log", + "@logShare": { + "description": "Action - share logs file" + }, + "logEmpty": "Belum ada log", + "@logEmpty": { + "description": "Empty state title" + }, + "logCopied": "Log disalin ke clipboard", + "@logCopied": { + "description": "Snackbar - logs copied" + }, + "logSearchHint": "Cari log...", + "@logSearchHint": { + "description": "Log search placeholder" + }, + "logFilterLevel": "Level", + "@logFilterLevel": { + "description": "Filter by log level" + }, + "logFilterSection": "Filter", + "@logFilterSection": { + "description": "Filter section title" + }, + "logShareLogs": "Bagikan log", + "@logShareLogs": { + "description": "Share button tooltip" + }, + "logClearLogs": "Hapus log", + "@logClearLogs": { + "description": "Clear button tooltip" + }, + "logClearLogsTitle": "Hapus Log", + "@logClearLogsTitle": { + "description": "Clear logs dialog title" + }, + "logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?", + "@logClearLogsMessage": { + "description": "Clear logs confirmation message" + }, + "logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI", + "@logIspBlocking": { + "description": "Error category - ISP blocking" + }, + "logRateLimited": "DIBATASI", + "@logRateLimited": { + "description": "Error category - rate limiting" + }, + "logNetworkError": "ERROR JARINGAN", + "@logNetworkError": { + "description": "Error category - network issues" + }, + "logTrackNotFound": "LAGU TIDAK DITEMUKAN", + "@logTrackNotFound": { + "description": "Error category - missing tracks" + }, + "logFilterBySeverity": "Filter log berdasarkan tingkat keparahan", + "@logFilterBySeverity": { + "description": "Filter dialog title" + }, + "logNoLogsYet": "Belum ada log", + "@logNoLogsYet": { + "description": "Empty state title" + }, + "logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi", + "@logNoLogsYetSubtitle": { + "description": "Empty state subtitle" + }, + "logIssueSummary": "Ringkasan Masalah", + "@logIssueSummary": { + "description": "Section header for error summary" + }, + "logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan", + "@logIspBlockingDescription": { + "description": "ISP blocking explanation" + }, + "logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8", + "@logIspBlockingSuggestion": { + "description": "ISP blocking fix suggestion" + }, + "logRateLimitedDescription": "Terlalu banyak permintaan ke layanan", + "@logRateLimitedDescription": { + "description": "Rate limit explanation" + }, + "logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi", + "@logRateLimitedSuggestion": { + "description": "Rate limit fix suggestion" + }, + "logNetworkErrorDescription": "Masalah koneksi terdeteksi", + "@logNetworkErrorDescription": { + "description": "Network error explanation" + }, + "logNetworkErrorSuggestion": "Periksa koneksi internet Anda", + "@logNetworkErrorSuggestion": { + "description": "Network error fix suggestion" + }, + "logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan", + "@logTrackNotFoundDescription": { + "description": "Track not found explanation" + }, + "logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless", + "@logTrackNotFoundSuggestion": { + "description": "Track not found explanation" + }, + "logTotalErrors": "Total error: {count}", + "@logTotalErrors": { + "description": "Error count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logAffected": "Terpengaruh: {domains}", + "@logAffected": { + "description": "Affected domains display", + "placeholders": { + "domains": { + "type": "String" + } + } + }, + "logEntriesFiltered": "Entri ({count} difilter)", + "@logEntriesFiltered": { + "description": "Log count with filter active", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logEntries": "Entri ({count})", + "@logEntries": { + "description": "Total log count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "credentialsTitle": "Kredensial Spotify", + "@credentialsTitle": { + "description": "Credentials dialog title" + }, + "credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.", + "@credentialsDescription": { + "description": "Credentials dialog explanation" + }, + "credentialsClientId": "Client ID", + "@credentialsClientId": { + "description": "Client ID field label - DO NOT TRANSLATE" + }, + "credentialsClientIdHint": "Tempel Client ID", + "@credentialsClientIdHint": { + "description": "Client ID placeholder" + }, + "credentialsClientSecret": "Client Secret", + "@credentialsClientSecret": { + "description": "Client Secret field label - DO NOT TRANSLATE" + }, + "credentialsClientSecretHint": "Tempel Client Secret", + "@credentialsClientSecretHint": { + "description": "Client Secret placeholder" + }, + "channelStable": "Stabil", + "@channelStable": { + "description": "Update channel - stable releases" + }, + "channelPreview": "Preview", + "@channelPreview": { + "description": "Update channel - beta/preview releases" + }, + "sectionSearchSource": "Sumber Pencarian", + "@sectionSearchSource": { + "description": "Settings section header" + }, + "sectionDownload": "Unduhan", + "@sectionDownload": { + "description": "Settings section header" + }, + "sectionPerformance": "Performa", + "@sectionPerformance": { + "description": "Settings section header" + }, + "sectionApp": "Aplikasi", + "@sectionApp": { + "description": "Settings section header" + }, + "sectionData": "Data", + "@sectionData": { + "description": "Settings section header" + }, + "sectionDebug": "Debug", + "@sectionDebug": { + "description": "Settings section header" + }, + "sectionService": "Layanan", + "@sectionService": { + "description": "Settings section header" + }, + "sectionAudioQuality": "Kualitas Audio", + "@sectionAudioQuality": { + "description": "Settings section header" + }, + "sectionFileSettings": "Pengaturan File", + "@sectionFileSettings": { + "description": "Settings section header" + }, + "sectionColor": "Warna", + "@sectionColor": { + "description": "Settings section header" + }, + "sectionTheme": "Tema", + "@sectionTheme": { + "description": "Settings section header" + }, + "sectionLayout": "Tata Letak", + "@sectionLayout": { + "description": "Settings section header" + }, + "sectionLanguage": "Bahasa", + "@sectionLanguage": { + "description": "Settings section header for language" + }, + "appearanceLanguage": "Bahasa Aplikasi", + "@appearanceLanguage": { + "description": "Language setting title" + }, + "appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan", + "@appearanceLanguageSubtitle": { + "description": "Language setting subtitle" + }, + "settingsAppearanceSubtitle": "Tema, warna, tampilan", + "@settingsAppearanceSubtitle": { + "description": "Appearance settings description" + }, + "settingsDownloadSubtitle": "Layanan, kualitas, format nama file", + "@settingsDownloadSubtitle": { + "description": "Download settings description" + }, + "settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan", + "@settingsOptionsSubtitle": { + "description": "Options settings description" + }, + "settingsExtensionsSubtitle": "Kelola provider unduhan", + "@settingsExtensionsSubtitle": { + "description": "Extensions settings description" + }, + "settingsLogsSubtitle": "Lihat log aplikasi untuk debugging", + "@settingsLogsSubtitle": { + "description": "Logs settings description" + }, + "loadingSharedLink": "Memuat link yang dibagikan...", + "@loadingSharedLink": { + "description": "Status when opening shared URL" + }, + "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", + "@pressBackAgainToExit": { + "description": "Exit confirmation message" + }, + "tracksHeader": "Lagu", + "@tracksHeader": { + "description": "Section header for track list" + }, + "downloadAllCount": "Unduh Semua ({count})", + "@downloadAllCount": { + "description": "Download all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "@tracksCount": { + "description": "Track count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trackCopyFilePath": "Salin lokasi file", + "@trackCopyFilePath": { + "description": "Action - copy file path" + }, + "trackRemoveFromDevice": "Hapus dari perangkat", + "@trackRemoveFromDevice": { + "description": "Action - delete downloaded file" + }, + "trackLoadLyrics": "Muat Lirik", + "@trackLoadLyrics": { + "description": "Action - fetch lyrics" + }, + "trackMetadata": "Metadata", + "@trackMetadata": { + "description": "Tab title - track metadata" + }, + "trackFileInfo": "Info File", + "@trackFileInfo": { + "description": "Tab title - file information" + }, + "trackLyrics": "Lirik", + "@trackLyrics": { + "description": "Tab title - lyrics" + }, + "trackFileNotFound": "File tidak ditemukan", + "@trackFileNotFound": { + "description": "Error - file doesn't exist" + }, + "trackOpenInDeezer": "Buka di Deezer", + "@trackOpenInDeezer": { + "description": "Action - open track in Deezer app" + }, + "trackOpenInSpotify": "Buka di Spotify", + "@trackOpenInSpotify": { + "description": "Action - open track in Spotify app" + }, + "trackTrackName": "Nama lagu", + "@trackTrackName": { + "description": "Metadata label - track title" + }, + "trackArtist": "Artis", + "@trackArtist": { + "description": "Metadata label - artist name" + }, + "trackAlbumArtist": "Artis album", + "@trackAlbumArtist": { + "description": "Metadata label - album artist" + }, + "trackAlbum": "Album", + "@trackAlbum": { + "description": "Metadata label - album name" + }, + "trackTrackNumber": "Nomor lagu", + "@trackTrackNumber": { + "description": "Metadata label - track number" + }, + "trackDiscNumber": "Nomor disc", + "@trackDiscNumber": { + "description": "Metadata label - disc number" + }, + "trackDuration": "Durasi", + "@trackDuration": { + "description": "Metadata label - track length" + }, + "trackAudioQuality": "Kualitas audio", + "@trackAudioQuality": { + "description": "Metadata label - audio quality" + }, + "trackReleaseDate": "Tanggal rilis", + "@trackReleaseDate": { + "description": "Metadata label - release date" + }, + "trackDownloaded": "Diunduh", + "@trackDownloaded": { + "description": "Metadata label - download date" + }, + "trackCopyLyrics": "Salin lirik", + "@trackCopyLyrics": { + "description": "Action - copy lyrics to clipboard" + }, + "trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini", + "@trackLyricsNotAvailable": { + "description": "Message when lyrics not found" + }, + "trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.", + "@trackLyricsTimeout": { + "description": "Message when lyrics request times out" + }, + "trackLyricsLoadFailed": "Gagal memuat lirik", + "@trackLyricsLoadFailed": { + "description": "Message when lyrics loading fails" + }, + "trackCopiedToClipboard": "Disalin ke clipboard", + "@trackCopiedToClipboard": { + "description": "Snackbar - content copied" + }, + "trackDeleteConfirmTitle": "Hapus dari perangkat?", + "@trackDeleteConfirmTitle": { + "description": "Delete confirmation title" + }, + "trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.", + "@trackDeleteConfirmMessage": { + "description": "Delete confirmation message" + }, + "trackCannotOpen": "Tidak dapat membuka: {message}", + "@trackCannotOpen": { + "description": "Error opening file", + "placeholders": { + "message": { + "type": "String" + } + } + }, + "dateToday": "Hari ini", + "@dateToday": { + "description": "Relative date - today" + }, + "dateYesterday": "Kemarin", + "@dateYesterday": { + "description": "Relative date - yesterday" + }, + "dateDaysAgo": "{count} hari lalu", + "@dateDaysAgo": { + "description": "Relative date - days ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateWeeksAgo": "{count} minggu lalu", + "@dateWeeksAgo": { + "description": "Relative date - weeks ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateMonthsAgo": "{count} bulan lalu", + "@dateMonthsAgo": { + "description": "Relative date - months ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "concurrentSequential": "Berurutan", + "@concurrentSequential": { + "description": "Download mode - one at a time" + }, + "concurrentParallel2": "2 Paralel", + "@concurrentParallel2": { + "description": "Download mode - 2 simultaneous" + }, + "concurrentParallel3": "3 Paralel", + "@concurrentParallel3": { + "description": "Download mode - 3 simultaneous" + }, + "tapToSeeError": "Ketuk untuk melihat detail error", + "@tapToSeeError": { + "description": "Tooltip for failed download" + }, + "storeFilterAll": "Semua", + "@storeFilterAll": { + "description": "Store filter - all extensions" + }, + "storeFilterMetadata": "Metadata", + "@storeFilterMetadata": { + "description": "Store filter - metadata providers" + }, + "storeFilterDownload": "Unduhan", + "@storeFilterDownload": { + "description": "Store filter - download providers" + }, + "storeFilterUtility": "Utilitas", + "@storeFilterUtility": { + "description": "Store filter - utility extensions" + }, + "storeFilterLyrics": "Lirik", + "@storeFilterLyrics": { + "description": "Store filter - lyrics providers" + }, + "storeFilterIntegration": "Integrasi", + "@storeFilterIntegration": { + "description": "Store filter - integrations" + }, + "storeClearFilters": "Hapus filter", + "@storeClearFilters": { + "description": "Button to clear all filters" + }, + "storeNoResults": "Tidak ada ekstensi ditemukan", + "@storeNoResults": { + "description": "Empty state when no extensions match filters" + }, + "extensionProviderPriority": "Prioritas Provider", + "@extensionProviderPriority": { + "description": "Extension capability - provider priority" + }, + "extensionInstallButton": "Pasang Ekstensi", + "@extensionInstallButton": { + "description": "Button to install extension" + }, + "extensionDefaultProvider": "Default (Deezer/Spotify)", + "@extensionDefaultProvider": { + "description": "Default search provider option" + }, + "extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan", + "@extensionDefaultProviderSubtitle": { + "description": "Subtitle for default provider" + }, + "extensionAuthor": "Pembuat", + "@extensionAuthor": { + "description": "Extension detail - author" + }, + "extensionId": "ID", + "@extensionId": { + "description": "Extension detail - unique ID" + }, + "extensionError": "Error", + "@extensionError": { + "description": "Extension detail - error message" + }, + "extensionCapabilities": "Kemampuan", + "@extensionCapabilities": { + "description": "Section header - extension features" + }, + "extensionMetadataProvider": "Provider Metadata", + "@extensionMetadataProvider": { + "description": "Capability - provides metadata" + }, + "extensionDownloadProvider": "Provider Unduhan", + "@extensionDownloadProvider": { + "description": "Capability - provides downloads" + }, + "extensionLyricsProvider": "Provider Lirik", + "@extensionLyricsProvider": { + "description": "Capability - provides lyrics" + }, + "extensionUrlHandler": "Penanganan URL", + "@extensionUrlHandler": { + "description": "Capability - handles URLs" + }, + "extensionQualityOptions": "Opsi Kualitas", + "@extensionQualityOptions": { + "description": "Capability - quality selection" + }, + "extensionPostProcessingHooks": "Hook Pasca-Pemrosesan", + "@extensionPostProcessingHooks": { + "description": "Capability - post-processing" + }, + "extensionPermissions": "Izin", + "@extensionPermissions": { + "description": "Section header - required permissions" + }, + "extensionSettings": "Pengaturan", + "@extensionSettings": { + "description": "Section header - extension settings" + }, + "extensionRemoveButton": "Hapus Ekstensi", + "@extensionRemoveButton": { + "description": "Button to uninstall extension" + }, + "extensionUpdated": "Diperbarui", + "@extensionUpdated": { + "description": "Extension detail - last update" + }, + "extensionMinAppVersion": "Versi App Minimum", + "@extensionMinAppVersion": { + "description": "Extension detail - minimum app version" + }, + "extensionCustomTrackMatching": "Pencocokan Lagu Kustom", + "@extensionCustomTrackMatching": { + "description": "Capability - custom track matching algorithm" + }, + "extensionPostProcessing": "Pasca-Pemrosesan", + "@extensionPostProcessing": { + "description": "Capability - post-download processing" + }, + "extensionHooksAvailable": "{count} hook tersedia", + "@extensionHooksAvailable": { + "description": "Post-processing hooks count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionPatternsCount": "{count} pola", + "@extensionPatternsCount": { + "description": "URL patterns count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionStrategy": "Strategi: {strategy}", + "@extensionStrategy": { + "description": "Track matching strategy name", + "placeholders": { + "strategy": { + "type": "String" + } + } + }, + "extensionsProviderPrioritySection": "Prioritas Provider", + "@extensionsProviderPrioritySection": { + "description": "Section header - provider priority" + }, + "extensionsInstalledSection": "Ekstensi Terpasang", + "@extensionsInstalledSection": { + "description": "Section header - installed extensions" + }, + "extensionsNoExtensions": "Tidak ada ekstensi terpasang", + "@extensionsNoExtensions": { + "description": "Empty state - no extensions" + }, + "extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru", + "@extensionsNoExtensionsSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsInstallButton": "Pasang Ekstensi", + "@extensionsInstallButton": { + "description": "Button to install extension from file" + }, + "extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.", + "@extensionsInfoTip": { + "description": "Security warning about extensions" + }, + "extensionsInstalledSuccess": "Ekstensi berhasil dipasang", + "@extensionsInstalledSuccess": { + "description": "Success message after install" + }, + "extensionsDownloadPriority": "Prioritas Unduhan", + "@extensionsDownloadPriority": { + "description": "Setting - download provider order" + }, + "extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan", + "@extensionsDownloadPrioritySubtitle": { + "description": "Subtitle for download priority" + }, + "extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan", + "@extensionsNoDownloadProvider": { + "description": "Empty state - no download providers" + }, + "extensionsMetadataPriority": "Prioritas Metadata", + "@extensionsMetadataPriority": { + "description": "Setting - metadata provider order" + }, + "extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata", + "@extensionsMetadataPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata", + "@extensionsNoMetadataProvider": { + "description": "Empty state - no metadata providers" + }, + "extensionsSearchProvider": "Provider Pencarian", + "@extensionsSearchProvider": { + "description": "Setting - search provider selection" + }, + "extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom", + "@extensionsNoCustomSearch": { + "description": "Empty state - no search providers" + }, + "extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu", + "@extensionsSearchProviderDescription": { + "description": "Search provider setting description" + }, + "extensionsCustomSearch": "Pencarian kustom", + "@extensionsCustomSearch": { + "description": "Label for custom search provider" + }, + "extensionsErrorLoading": "Error memuat ekstensi", + "@extensionsErrorLoading": { + "description": "Error message when extension fails to load" + }, + "qualityFlacLossless": "FLAC Lossless", + "@qualityFlacLossless": { + "description": "Quality option - CD quality FLAC" + }, + "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", + "@qualityFlacLosslessSubtitle": { + "description": "Technical spec for lossless" + }, + "qualityHiResFlac": "Hi-Res FLAC", + "@qualityHiResFlac": { + "description": "Quality option - high resolution FLAC" + }, + "qualityHiResFlacSubtitle": "24-bit / hingga 96kHz", + "@qualityHiResFlacSubtitle": { + "description": "Technical spec for hi-res" + }, + "qualityHiResFlacMax": "Hi-Res FLAC Max", + "@qualityHiResFlacMax": { + "description": "Quality option - maximum resolution FLAC" + }, + "qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz", + "@qualityHiResFlacMaxSubtitle": { + "description": "Technical spec for hi-res max" + }, + "qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan", + "@qualityNote": { + "description": "Note about quality availability" + }, + "downloadAskBeforeDownload": "Tanya Sebelum Unduh", + "@downloadAskBeforeDownload": { + "description": "Setting - show quality picker" + }, + "downloadDirectory": "Direktori Unduhan", + "@downloadDirectory": { + "description": "Setting - download folder" + }, + "downloadSeparateSinglesFolder": "Folder Singles Terpisah", + "@downloadSeparateSinglesFolder": { + "description": "Setting - separate folder for singles" + }, + "downloadAlbumFolderStructure": "Struktur Folder Album", + "@downloadAlbumFolderStructure": { + "description": "Setting - album folder organization" + }, + "downloadSaveFormat": "Simpan Format", + "@downloadSaveFormat": { + "description": "Setting - output file format" + }, + "downloadSelectService": "Pilih Layanan", + "@downloadSelectService": { + "description": "Dialog title - choose download service" + }, + "downloadSelectQuality": "Pilih Kualitas", + "@downloadSelectQuality": { + "description": "Dialog title - choose audio quality" + }, + "downloadFrom": "Unduh Dari", + "@downloadFrom": { + "description": "Label - download source" + }, + "downloadDefaultQualityLabel": "Kualitas Default", + "@downloadDefaultQualityLabel": { + "description": "Label - default quality setting" + }, + "downloadBestAvailable": "Terbaik tersedia", + "@downloadBestAvailable": { + "description": "Quality option - highest available" + }, + "folderNone": "Tidak ada", + "@folderNone": { + "description": "Folder option - no organization" + }, + "folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan", + "@folderNoneSubtitle": { + "description": "Subtitle for no folder organization" + }, + "folderArtist": "Artis", + "@folderArtist": { + "description": "Folder option - by artist" + }, + "folderArtistSubtitle": "Nama Artis/namafile", + "@folderArtistSubtitle": { + "description": "Folder structure example" + }, + "folderAlbum": "Album", + "@folderAlbum": { + "description": "Folder option - by album" + }, + "folderAlbumSubtitle": "Nama Album/namafile", + "@folderAlbumSubtitle": { + "description": "Folder structure example" + }, + "folderArtistAlbum": "Artis/Album", + "@folderArtistAlbum": { + "description": "Folder option - nested" + }, + "folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile", + "@folderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "serviceTidal": "Tidal", + "@serviceTidal": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceQobuz": "Qobuz", + "@serviceQobuz": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceAmazon": "Amazon", + "@serviceAmazon": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceDeezer": "Deezer", + "@serviceDeezer": { + "description": "Service name - DO NOT TRANSLATE" + }, + "serviceSpotify": "Spotify", + "@serviceSpotify": { + "description": "Service name - DO NOT TRANSLATE" + }, + "appearanceAmoledDark": "AMOLED Gelap", + "@appearanceAmoledDark": { + "description": "Theme option - pure black" + }, + "appearanceAmoledDarkSubtitle": "Latar belakang hitam murni", + "@appearanceAmoledDarkSubtitle": { + "description": "Subtitle for AMOLED dark" + }, + "appearanceChooseAccentColor": "Pilih Warna Aksen", + "@appearanceChooseAccentColor": { + "description": "Color picker dialog title" + }, + "appearanceChooseTheme": "Mode Tema", + "@appearanceChooseTheme": { + "description": "Theme picker dialog title" + }, + "queueTitle": "Antrian Unduhan", + "@queueTitle": { + "description": "Queue screen title" + }, + "queueClearAll": "Hapus Semua", + "@queueClearAll": { + "description": "Button - clear all queue items" + }, + "queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?", + "@queueClearAllMessage": { + "description": "Clear queue confirmation" + }, + "queueEmpty": "Tidak ada unduhan dalam antrian", + "@queueEmpty": { + "description": "Empty queue state title" + }, + "queueEmptySubtitle": "Tambahkan lagu dari layar beranda", + "@queueEmptySubtitle": { + "description": "Empty queue state subtitle" + }, + "queueClearCompleted": "Hapus yang selesai", + "@queueClearCompleted": { + "description": "Button - clear finished downloads" + }, + "queueDownloadFailed": "Unduhan Gagal", + "@queueDownloadFailed": { + "description": "Error dialog title" + }, + "queueTrackLabel": "Lagu:", + "@queueTrackLabel": { + "description": "Label in error dialog" + }, + "queueArtistLabel": "Artis:", + "@queueArtistLabel": { + "description": "Label in error dialog" + }, + "queueErrorLabel": "Error:", + "@queueErrorLabel": { + "description": "Label in error dialog" + }, + "queueUnknownError": "Error tidak diketahui", + "@queueUnknownError": { + "description": "Fallback error message" + }, + "albumFolderArtistAlbum": "Artis / Album", + "@albumFolderArtistAlbum": { + "description": "Album folder option" + }, + "albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/", + "@albumFolderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderArtistYearAlbum": "Artis / [Tahun] Album", + "@albumFolderArtistYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/", + "@albumFolderArtistYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderAlbumOnly": "Album Saja", + "@albumFolderAlbumOnly": { + "description": "Album folder option" + }, + "albumFolderAlbumOnlySubtitle": "Albums/Nama Album/", + "@albumFolderAlbumOnlySubtitle": { + "description": "Folder structure example" + }, + "albumFolderYearAlbum": "[Tahun] Album", + "@albumFolderYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/", + "@albumFolderYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "downloadedAlbumDeleteSelected": "Hapus yang Dipilih", + "@downloadedAlbumDeleteSelected": { + "description": "Button - delete selected tracks" + }, + "downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.", + "@downloadedAlbumDeleteMessage": { + "description": "Delete confirmation with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumTracksHeader": "Lagu", + "@downloadedAlbumTracksHeader": { + "description": "Section header for tracks" + }, + "downloadedAlbumDownloadedCount": "{count} diunduh", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectedCount": "{count} dipilih", + "@downloadedAlbumSelectedCount": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumAllSelected": "Semua lagu dipilih", + "@downloadedAlbumAllSelected": { + "description": "Status - all items selected" + }, + "downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih", + "@downloadedAlbumTapToSelect": { + "description": "Selection hint" + }, + "downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", + "@downloadedAlbumDeleteCount": { + "description": "Delete button text with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus", + "@downloadedAlbumSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "utilityFunctions": "Fungsi Utilitas", + "@utilityFunctions": { + "description": "Extension capability - utility functions" + }, "recentTypeArtist": "Artis", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, "recentTypeSong": "Lagu", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, "recentTypePlaylist": "Playlist", - + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, "recentPlaylistInfo": "Playlist: {name}", - "errorGeneric": "Error: {message}" -} + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": { + "type": "String", + "description": "Error message" + } + } + } +} \ No newline at end of file From 7e1aca33a52adfffc8953273923ba89b7134b74c Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:29 +0700 Subject: [PATCH 11/48] New translations app_en.arb (Hindi) --- lib/l10n/arb/app_hi.arb | 68 ++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 048dd1ed..d286a4ea 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -642,6 +642,20 @@ } } }, + "artistPopular": "Popular", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} monthly listeners", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, "trackMetadataTitle": "Track Info", "@trackMetadataTitle": { "description": "Track metadata screen title" @@ -1851,27 +1865,15 @@ }, "sectionLanguage": "Language", "@sectionLanguage": { - "description": "Settings section header for language selection" + "description": "Settings section header for language" }, "appearanceLanguage": "App Language", "@appearanceLanguage": { - "description": "Setting title for language selection" + "description": "Language setting title" }, "appearanceLanguageSubtitle": "Choose your preferred language", "@appearanceLanguageSubtitle": { - "description": "Subtitle for language setting" - }, - "languageSystem": "System Default", - "@languageSystem": { - "description": "Use device system language" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" + "description": "Language setting subtitle" }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { @@ -2573,5 +2575,41 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" + }, + "recentTypeArtist": "Artist", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Song", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": { + "type": "String", + "description": "Error message" + } + } } } \ No newline at end of file From 12db11d559d5b991fe809b29b46d630f00a46d81 Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:29:33 +0700 Subject: [PATCH 12/48] New translations app_en.arb (Spanish) --- lib/l10n/arb/app_es-ES.arb | 1124 ++++++++++++++++++------------------ 1 file changed, 562 insertions(+), 562 deletions(-) diff --git a/lib/l10n/arb/app_es-ES.arb b/lib/l10n/arb/app_es-ES.arb index cf4def73..c5d40717 100644 --- a/lib/l10n/arb/app_es-ES.arb +++ b/lib/l10n/arb/app_es-ES.arb @@ -5,35 +5,35 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "appDescription": "Descargue pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.", "@appDescription": { "description": "App description shown in about page" }, - "navHome": "Home", + "navHome": "Inicio", "@navHome": { "description": "Bottom navigation - Home tab" }, - "navHistory": "History", + "navHistory": "Historial", "@navHistory": { "description": "Bottom navigation - History tab" }, - "navSettings": "Settings", + "navSettings": "Ajustes", "@navSettings": { "description": "Bottom navigation - Settings tab" }, - "navStore": "Store", + "navStore": "Tienda", "@navStore": { "description": "Bottom navigation - Extension store tab" }, - "homeTitle": "Home", + "homeTitle": "Inicio", "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", + "homeSearchHint": "Pegar URL Spotify o buscar...", "@homeSearchHint": { "description": "Placeholder text in search box" }, - "homeSearchHintExtension": "Search with {extensionName}...", + "homeSearchHintExtension": "Buscar con {extensionName}...", "@homeSearchHintExtension": { "description": "Placeholder when extension search is active", "placeholders": { @@ -43,23 +43,23 @@ } } }, - "homeSubtitle": "Paste a Spotify link or search by name", + "homeSubtitle": "Pegar enlace de Spotify o buscar por nombre", "@homeSubtitle": { "description": "Subtitle shown below search box" }, - "homeSupports": "Supports: Track, Album, Playlist, Artist URLs", + "homeSupports": "Soportes: Pista, Álbum, Lista de reproducción, URLs de Artistas", "@homeSupports": { "description": "Info text about supported URL types" }, - "homeRecent": "Recent", + "homeRecent": "Recientes", "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", + "historyTitle": "Historial", "@historyTitle": { "description": "History screen title" }, - "historyDownloading": "Downloading ({count})", + "historyDownloading": "Descargando ({count})", "@historyDownloading": { "description": "Tab showing active downloads count", "placeholders": { @@ -69,23 +69,23 @@ } } }, - "historyDownloaded": "Downloaded", + "historyDownloaded": "Descargado", "@historyDownloaded": { "description": "Tab showing completed downloads" }, - "historyFilterAll": "All", + "historyFilterAll": "Todo", "@historyFilterAll": { "description": "Filter chip - show all items" }, - "historyFilterAlbums": "Albums", + "historyFilterAlbums": "Álbumes", "@historyFilterAlbums": { "description": "Filter chip - show albums only" }, - "historyFilterSingles": "Singles", + "historyFilterSingles": "Pistas", "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", "@historyTracksCount": { "description": "Track count with plural form", "placeholders": { @@ -94,7 +94,7 @@ } } }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", + "historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}", "@historyAlbumsCount": { "description": "Album count with plural form", "placeholders": { @@ -103,107 +103,107 @@ } } }, - "historyNoDownloads": "No download history", + "historyNoDownloads": "No hay historial de descargas", "@historyNoDownloads": { "description": "Empty state title" }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", + "historyNoDownloadsSubtitle": "Las pistas descargadas aparecerán aquí", "@historyNoDownloadsSubtitle": { "description": "Empty state subtitle" }, - "historyNoAlbums": "No album downloads", + "historyNoAlbums": "No hay descargas de álbum", "@historyNoAlbums": { "description": "Empty state when filtering albums" }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", + "historyNoAlbumsSubtitle": "Descargar múltiples pistas de un álbum para verlas aquí", "@historyNoAlbumsSubtitle": { "description": "Empty state subtitle for albums filter" }, - "historyNoSingles": "No single downloads", + "historyNoSingles": "No hay descargas", "@historyNoSingles": { "description": "Empty state when filtering singles" }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", + "historyNoSinglesSubtitle": "Las descargas de una sola pista aparecerán aquí", "@historyNoSinglesSubtitle": { "description": "Empty state subtitle for singles filter" }, - "settingsTitle": "Settings", + "settingsTitle": "Ajustes", "@settingsTitle": { "description": "Settings screen title" }, - "settingsDownload": "Download", + "settingsDownload": "Descargar", "@settingsDownload": { "description": "Settings section - download options" }, - "settingsAppearance": "Appearance", + "settingsAppearance": "Apariencia", "@settingsAppearance": { "description": "Settings section - visual customization" }, - "settingsOptions": "Options", + "settingsOptions": "Opciones", "@settingsOptions": { "description": "Settings section - app options" }, - "settingsExtensions": "Extensions", + "settingsExtensions": "Extensiones", "@settingsExtensions": { "description": "Settings section - extension management" }, - "settingsAbout": "About", + "settingsAbout": "Acerca de", "@settingsAbout": { "description": "Settings section - app info" }, - "downloadTitle": "Download", + "downloadTitle": "Descargar", "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", + "downloadLocation": "Ubicación de descarga", "@downloadLocation": { "description": "Setting for download folder" }, - "downloadLocationSubtitle": "Choose where to save files", + "downloadLocationSubtitle": "Elija dónde guardar los archivos", "@downloadLocationSubtitle": { "description": "Subtitle for download location" }, - "downloadLocationDefault": "Default location", + "downloadLocationDefault": "Ubicación predeterminada", "@downloadLocationDefault": { "description": "Shown when using default folder" }, - "downloadDefaultService": "Default Service", + "downloadDefaultService": "Servicio por defecto", "@downloadDefaultService": { "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" }, - "downloadDefaultServiceSubtitle": "Service used for downloads", + "downloadDefaultServiceSubtitle": "Servicio usado para descargas", "@downloadDefaultServiceSubtitle": { "description": "Subtitle for default service" }, - "downloadDefaultQuality": "Default Quality", + "downloadDefaultQuality": "Calidad por defecto", "@downloadDefaultQuality": { "description": "Setting for audio quality" }, - "downloadAskQuality": "Ask Quality Before Download", + "downloadAskQuality": "Preguntar calidad antes de descargar", "@downloadAskQuality": { "description": "Toggle to show quality picker" }, - "downloadAskQualitySubtitle": "Show quality picker for each download", + "downloadAskQualitySubtitle": "Mostrar selector de calidad para cada descarga", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" }, - "downloadFilenameFormat": "Filename Format", + "downloadFilenameFormat": "Formato del nombre del archivo", "@downloadFilenameFormat": { "description": "Setting for output filename pattern" }, - "downloadFolderOrganization": "Folder Organization", + "downloadFolderOrganization": "Organización de carpetas", "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", + "downloadSeparateSingles": "Separar Pistas", "@downloadSeparateSingles": { "description": "Toggle to separate single tracks" }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", + "downloadSeparateSinglesSubtitle": "Colocar pistas individuales en una carpeta separada", "@downloadSeparateSinglesSubtitle": { "description": "Subtitle for separate singles toggle" }, - "qualityBest": "Best Available", + "qualityBest": "Mejor disponible", "@qualityBest": { "description": "Audio quality option - highest available" }, @@ -219,67 +219,67 @@ "@quality128": { "description": "Audio quality option - 128kbps MP3" }, - "appearanceTitle": "Appearance", + "appearanceTitle": "Apariencia", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", + "appearanceTheme": "Tema", "@appearanceTheme": { "description": "Theme mode setting" }, - "appearanceThemeSystem": "System", + "appearanceThemeSystem": "Sistema", "@appearanceThemeSystem": { "description": "Follow system theme" }, - "appearanceThemeLight": "Light", + "appearanceThemeLight": "Claro", "@appearanceThemeLight": { "description": "Light theme" }, - "appearanceThemeDark": "Dark", + "appearanceThemeDark": "Oscuro", "@appearanceThemeDark": { "description": "Dark theme" }, - "appearanceDynamicColor": "Dynamic Color", + "appearanceDynamicColor": "Color dinámico", "@appearanceDynamicColor": { "description": "Material You dynamic colors" }, - "appearanceDynamicColorSubtitle": "Use colors from your wallpaper", + "appearanceDynamicColorSubtitle": "Usar colores de tu fondo de pantalla", "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", + "appearanceAccentColor": "Color Secundario", "@appearanceAccentColor": { "description": "Custom accent color picker" }, - "appearanceHistoryView": "History View", + "appearanceHistoryView": "Vista de Historial", "@appearanceHistoryView": { "description": "Layout style for history" }, - "appearanceHistoryViewList": "List", + "appearanceHistoryViewList": "Lista", "@appearanceHistoryViewList": { "description": "List layout option" }, - "appearanceHistoryViewGrid": "Grid", + "appearanceHistoryViewGrid": "Cuadrícula", "@appearanceHistoryViewGrid": { "description": "Grid layout option" }, - "optionsTitle": "Options", + "optionsTitle": "Opciones", "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", + "optionsSearchSource": "Buscar Fuente", "@optionsSearchSource": { "description": "Section for search provider settings" }, - "optionsPrimaryProvider": "Primary Provider", + "optionsPrimaryProvider": "Proveedor Principal", "@optionsPrimaryProvider": { "description": "Main search provider setting" }, - "optionsPrimaryProviderSubtitle": "Service used when searching by track name.", + "optionsPrimaryProviderSubtitle": "Servicio usado al buscar por nombre de la pista.", "@optionsPrimaryProviderSubtitle": { "description": "Subtitle for primary provider" }, - "optionsUsingExtension": "Using extension: {extensionName}", + "optionsUsingExtension": "Usando la extensión: {extensionName}", "@optionsUsingExtension": { "description": "Shows active extension name", "placeholders": { @@ -288,55 +288,55 @@ } } }, - "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", + "optionsSwitchBack": "Toque Deezer o Spotify para volver desde la extensión", "@optionsSwitchBack": { "description": "Hint to switch back to built-in providers" }, - "optionsAutoFallback": "Auto Fallback", + "optionsAutoFallback": "Alternativa automática", "@optionsAutoFallback": { "description": "Auto-retry with other services" }, - "optionsAutoFallbackSubtitle": "Try other services if download fails", + "optionsAutoFallbackSubtitle": "Pruebe otros servicios si falla la descarga", "@optionsAutoFallbackSubtitle": { "description": "Subtitle for auto fallback" }, - "optionsUseExtensionProviders": "Use Extension Providers", + "optionsUseExtensionProviders": "Usar proveedores de extensiones", "@optionsUseExtensionProviders": { "description": "Enable extension download providers" }, - "optionsUseExtensionProvidersOn": "Extensions will be tried first", + "optionsUseExtensionProvidersOn": "Las extensiones serán probadas primero", "@optionsUseExtensionProvidersOn": { "description": "Status when extension providers enabled" }, - "optionsUseExtensionProvidersOff": "Using built-in providers only", + "optionsUseExtensionProvidersOff": "Utilizando sólo proveedores integrados", "@optionsUseExtensionProvidersOff": { "description": "Status when extension providers disabled" }, - "optionsEmbedLyrics": "Embed Lyrics", + "optionsEmbedLyrics": "Incrustar Letras", "@optionsEmbedLyrics": { "description": "Embed lyrics in audio files" }, - "optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files", + "optionsEmbedLyricsSubtitle": "Insertar letras sincronizadas en archivos FLAC", "@optionsEmbedLyricsSubtitle": { "description": "Subtitle for embed lyrics" }, - "optionsMaxQualityCover": "Max Quality Cover", + "optionsMaxQualityCover": "Carátula de calidad máxima", "@optionsMaxQualityCover": { "description": "Download highest quality album art" }, - "optionsMaxQualityCoverSubtitle": "Download highest resolution cover art", + "optionsMaxQualityCoverSubtitle": "Descargar carátula de resolución máxima", "@optionsMaxQualityCoverSubtitle": { "description": "Subtitle for max quality cover" }, - "optionsConcurrentDownloads": "Concurrent Downloads", + "optionsConcurrentDownloads": "Descargas Simultáneas", "@optionsConcurrentDownloads": { "description": "Number of parallel downloads" }, - "optionsConcurrentSequential": "Sequential (1 at a time)", + "optionsConcurrentSequential": "Secuencial (1 a la vez)", "@optionsConcurrentSequential": { "description": "Download one at a time" }, - "optionsConcurrentParallel": "{count} parallel downloads", + "optionsConcurrentParallel": "{count} descargas paralelas", "@optionsConcurrentParallel": { "description": "Multiple parallel downloads", "placeholders": { @@ -345,67 +345,67 @@ } } }, - "optionsConcurrentWarning": "Parallel downloads may trigger rate limiting", + "optionsConcurrentWarning": "Las descargas paralelas pueden activar la limitación de velocidad", "@optionsConcurrentWarning": { "description": "Warning about rate limits" }, - "optionsExtensionStore": "Extension Store", + "optionsExtensionStore": "Tienda de extensiones", "@optionsExtensionStore": { "description": "Show/hide store tab" }, - "optionsExtensionStoreSubtitle": "Show Store tab in navigation", + "optionsExtensionStoreSubtitle": "Mostrar pestaña de tienda en la navegación", "@optionsExtensionStoreSubtitle": { "description": "Subtitle for extension store toggle" }, - "optionsCheckUpdates": "Check for Updates", + "optionsCheckUpdates": "Comprobar actualizaciones", "@optionsCheckUpdates": { "description": "Auto update check toggle" }, - "optionsCheckUpdatesSubtitle": "Notify when new version is available", + "optionsCheckUpdatesSubtitle": "Notificar cuando una nueva versión esté disponible", "@optionsCheckUpdatesSubtitle": { "description": "Subtitle for update check" }, - "optionsUpdateChannel": "Update Channel", + "optionsUpdateChannel": "Tipo de actualizaciones", "@optionsUpdateChannel": { "description": "Stable vs preview releases" }, - "optionsUpdateChannelStable": "Stable releases only", + "optionsUpdateChannelStable": "Sólo versiones estables", "@optionsUpdateChannelStable": { "description": "Only stable updates" }, - "optionsUpdateChannelPreview": "Get preview releases", + "optionsUpdateChannelPreview": "Versión preliminar", "@optionsUpdateChannelPreview": { "description": "Include beta/preview updates" }, - "optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features", + "optionsUpdateChannelWarning": "La Versión preliminar puede contener errores o características incompletas", "@optionsUpdateChannelWarning": { "description": "Warning about preview channel" }, - "optionsClearHistory": "Clear Download History", + "optionsClearHistory": "Borrar el historial de descargas", "@optionsClearHistory": { "description": "Delete all download history" }, - "optionsClearHistorySubtitle": "Remove all downloaded tracks from history", + "optionsClearHistorySubtitle": "Eliminar todas las pistas descargadas del historial", "@optionsClearHistorySubtitle": { "description": "Subtitle for clear history" }, - "optionsDetailedLogging": "Detailed Logging", + "optionsDetailedLogging": "Registro detallado", "@optionsDetailedLogging": { "description": "Enable verbose logs for debugging" }, - "optionsDetailedLoggingOn": "Detailed logs are being recorded", + "optionsDetailedLoggingOn": "Registros detallados están siendo registrados", "@optionsDetailedLoggingOn": { "description": "Status when logging enabled" }, - "optionsDetailedLoggingOff": "Enable for bug reports", + "optionsDetailedLoggingOff": "Habilitar para informes de errores", "@optionsDetailedLoggingOff": { "description": "Status when logging disabled" }, - "optionsSpotifyCredentials": "Spotify Credentials", + "optionsSpotifyCredentials": "Credenciales de Spotify", "@optionsSpotifyCredentials": { "description": "Spotify API credentials setting" }, - "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", + "optionsSpotifyCredentialsConfigured": "ID de cliente: {clientId}...", "@optionsSpotifyCredentialsConfigured": { "description": "Shows configured client ID preview", "placeholders": { @@ -414,39 +414,39 @@ } } }, - "optionsSpotifyCredentialsRequired": "Required - tap to configure", + "optionsSpotifyCredentialsRequired": "Requerido - toque para configurar", "@optionsSpotifyCredentialsRequired": { "description": "Prompt to set up credentials" }, - "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", + "optionsSpotifyWarning": "Spotify requiere tus propias credenciales API. Obténgalas gratis de developer.spotify.com", "@optionsSpotifyWarning": { "description": "Info about Spotify API requirement" }, - "extensionsTitle": "Extensions", + "extensionsTitle": "Extensiones", "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", + "extensionsInstalled": "Extensiones instaladas", "@extensionsInstalled": { "description": "Section header for installed extensions" }, - "extensionsNone": "No extensions installed", + "extensionsNone": "No hay extensiones instaladas", "@extensionsNone": { "description": "Empty state title" }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", + "extensionsNoneSubtitle": "Instalar extensiones desde la pestaña Tienda", "@extensionsNoneSubtitle": { "description": "Empty state subtitle" }, - "extensionsEnabled": "Enabled", + "extensionsEnabled": "Habilitado", "@extensionsEnabled": { "description": "Extension status - active" }, - "extensionsDisabled": "Disabled", + "extensionsDisabled": "Deshabilitado", "@extensionsDisabled": { "description": "Extension status - inactive" }, - "extensionsVersion": "Version {version}", + "extensionsVersion": "Versión {version}", "@extensionsVersion": { "description": "Extension version display", "placeholders": { @@ -455,7 +455,7 @@ } } }, - "extensionsAuthor": "by {author}", + "extensionsAuthor": "por {author}", "@extensionsAuthor": { "description": "Extension author credit", "placeholders": { @@ -464,111 +464,111 @@ } } }, - "extensionsUninstall": "Uninstall", + "extensionsUninstall": "Desinstalar", "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", + "extensionsSetAsSearch": "Establecer como proveedor de búsqueda", "@extensionsSetAsSearch": { "description": "Use extension for search" }, - "storeTitle": "Extension Store", + "storeTitle": "Tienda de extensiones", "@storeTitle": { "description": "Store screen title" }, - "storeSearch": "Search extensions...", + "storeSearch": "Buscar extensiones...", "@storeSearch": { "description": "Store search placeholder" }, - "storeInstall": "Install", + "storeInstall": "Instalar", "@storeInstall": { "description": "Install extension button" }, - "storeInstalled": "Installed", + "storeInstalled": "Instalada", "@storeInstalled": { "description": "Already installed badge" }, - "storeUpdate": "Update", + "storeUpdate": "Actualizar", "@storeUpdate": { "description": "Update available button" }, - "aboutTitle": "About", + "aboutTitle": "Acerca de", "@aboutTitle": { "description": "About page title" }, - "aboutContributors": "Contributors", + "aboutContributors": "Colaboradores", "@aboutContributors": { "description": "Section for contributors" }, - "aboutMobileDeveloper": "Mobile version developer", + "aboutMobileDeveloper": "Desarrollador de versiones móviles", "@aboutMobileDeveloper": { "description": "Role description for mobile dev" }, - "aboutOriginalCreator": "Creator of the original SpotiFLAC", + "aboutOriginalCreator": "Creador original de SpotiFLAC", "@aboutOriginalCreator": { "description": "Role description for original creator" }, - "aboutLogoArtist": "The talented artist who created our beautiful app logo!", + "aboutLogoArtist": "¡El talentoso artista que creó nuestro hermoso logo!", "@aboutLogoArtist": { "description": "Role description for logo artist" }, - "aboutSpecialThanks": "Special Thanks", + "aboutSpecialThanks": "Agradecimientos especiales", "@aboutSpecialThanks": { "description": "Section for special thanks" }, - "aboutLinks": "Links", + "aboutLinks": "Enlaces", "@aboutLinks": { "description": "Section for external links" }, - "aboutMobileSource": "Mobile source code", + "aboutMobileSource": "Código fuente móvil", "@aboutMobileSource": { "description": "Link to mobile GitHub repo" }, - "aboutPCSource": "PC source code", + "aboutPCSource": "Código fuente de PC", "@aboutPCSource": { "description": "Link to PC GitHub repo" }, - "aboutReportIssue": "Report an issue", + "aboutReportIssue": "Reportar un problema", "@aboutReportIssue": { "description": "Link to report bugs" }, - "aboutReportIssueSubtitle": "Report any problems you encounter", + "aboutReportIssueSubtitle": "Reporta cualquier problema que encuentres", "@aboutReportIssueSubtitle": { "description": "Subtitle for report issue" }, - "aboutFeatureRequest": "Feature request", + "aboutFeatureRequest": "Sugerir una función", "@aboutFeatureRequest": { "description": "Link to suggest features" }, - "aboutFeatureRequestSubtitle": "Suggest new features for the app", + "aboutFeatureRequestSubtitle": "Sugerir nuevas funciones para la aplicación", "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, - "aboutSupport": "Support", + "aboutSupport": "Soporte", "@aboutSupport": { "description": "Section for support/donation links" }, - "aboutBuyMeCoffee": "Buy me a coffee", + "aboutBuyMeCoffee": "Invítame a un café", "@aboutBuyMeCoffee": { "description": "Donation link" }, - "aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi", + "aboutBuyMeCoffeeSubtitle": "Apoyar el desarrollo en Ko-fi", "@aboutBuyMeCoffeeSubtitle": { "description": "Subtitle for donation" }, - "aboutApp": "App", + "aboutApp": "Aplicación", "@aboutApp": { "description": "Section for app info" }, - "aboutVersion": "Version", + "aboutVersion": "Versión", "@aboutVersion": { "description": "Version info label" }, - "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", + "aboutBinimumDesc": "El creador de la API QQDL & Hi-Fi. ¡Sin esta API, las descargas de Tidal no existiría!", "@aboutBinimumDesc": { "description": "Credit description for binimum" }, - "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", + "aboutSachinsenalDesc": "El creador original del proyecto Hi-Fi. ¡La base de la integración de Tidal!", "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, @@ -576,27 +576,27 @@ "@aboutDoubleDouble": { "description": "Name of Amazon API service - DO NOT TRANSLATE" }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", + "aboutDoubleDoubleDesc": "API increible para descargas de Amazon Music. ¡Gracias por hacerla gratis!", "@aboutDoubleDoubleDesc": { "description": "Credit for DoubleDouble API" }, - "aboutDabMusic": "DAB Music", + "aboutDabMusic": "Música DAB", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" }, - "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", + "aboutDabMusicDesc": "La mejor API de streaming de Qobuz. ¡Las descargas de Hi-Res no serían posibles sin esto!", "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.", "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", + "albumTitle": "Álbum", "@albumTitle": { "description": "Album screen title" }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", + "albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", "@albumTracks": { "description": "Album track count", "placeholders": { @@ -605,35 +605,35 @@ } } }, - "albumDownloadAll": "Download All", + "albumDownloadAll": "Descargar Todo", "@albumDownloadAll": { "description": "Button to download all tracks" }, - "albumDownloadRemaining": "Download Remaining", + "albumDownloadRemaining": "Descargas Restantes", "@albumDownloadRemaining": { "description": "Button to download remaining tracks" }, - "playlistTitle": "Playlist", + "playlistTitle": "Lista de reproducción", "@playlistTitle": { "description": "Playlist screen title" }, - "artistTitle": "Artist", + "artistTitle": "Artista", "@artistTitle": { "description": "Artist screen title" }, - "artistAlbums": "Albums", + "artistAlbums": "Álbumes", "@artistAlbums": { "description": "Section header for artist albums" }, - "artistSingles": "Singles & EPs", + "artistSingles": "Pistas y EPs", "@artistSingles": { "description": "Section header for singles/EPs" }, - "artistCompilations": "Compilations", + "artistCompilations": "Compilaciones", "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", + "artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}", "@artistReleases": { "description": "Artist release count", "placeholders": { @@ -642,11 +642,11 @@ } } }, - "artistPopular": "Popular", + "artistPopular": "Populares", "@artistPopular": { "description": "Section header for popular/top tracks" }, - "artistMonthlyListeners": "{count} monthly listeners", + "artistMonthlyListeners": "{count} oyentes mensuales", "@artistMonthlyListeners": { "description": "Monthly listener count display", "placeholders": { @@ -656,123 +656,123 @@ } } }, - "trackMetadataTitle": "Track Info", + "trackMetadataTitle": "Información de pista", "@trackMetadataTitle": { "description": "Track metadata screen title" }, - "trackMetadataArtist": "Artist", + "trackMetadataArtist": "Artista", "@trackMetadataArtist": { "description": "Metadata field - artist name" }, - "trackMetadataAlbum": "Album", + "trackMetadataAlbum": "Álbum", "@trackMetadataAlbum": { "description": "Metadata field - album name" }, - "trackMetadataDuration": "Duration", + "trackMetadataDuration": "Duración", "@trackMetadataDuration": { "description": "Metadata field - track length" }, - "trackMetadataQuality": "Quality", + "trackMetadataQuality": "Calidad", "@trackMetadataQuality": { "description": "Metadata field - audio quality" }, - "trackMetadataPath": "File Path", + "trackMetadataPath": "Ruta del archivo", "@trackMetadataPath": { "description": "Metadata field - file location" }, - "trackMetadataDownloadedAt": "Downloaded", + "trackMetadataDownloadedAt": "Descargado", "@trackMetadataDownloadedAt": { "description": "Metadata field - download date" }, - "trackMetadataService": "Service", + "trackMetadataService": "Servicio", "@trackMetadataService": { "description": "Metadata field - download service used" }, - "trackMetadataPlay": "Play", + "trackMetadataPlay": "Reproducir", "@trackMetadataPlay": { "description": "Action button - play track" }, - "trackMetadataShare": "Share", + "trackMetadataShare": "Compartir", "@trackMetadataShare": { "description": "Action button - share track" }, - "trackMetadataDelete": "Delete", + "trackMetadataDelete": "Eliminar", "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", + "trackMetadataRedownload": "Volver a descargar", "@trackMetadataRedownload": { "description": "Action button - download again" }, - "trackMetadataOpenFolder": "Open Folder", + "trackMetadataOpenFolder": "Abrir carpeta", "@trackMetadataOpenFolder": { "description": "Action button - open containing folder" }, - "setupTitle": "Welcome to SpotiFLAC", + "setupTitle": "Bienvenido a SpotiFLAC", "@setupTitle": { "description": "Setup wizard title" }, - "setupSubtitle": "Let's get you started", + "setupSubtitle": "Comencemos", "@setupSubtitle": { "description": "Setup wizard subtitle" }, - "setupStoragePermission": "Storage Permission", + "setupStoragePermission": "Permiso de almacenamiento", "@setupStoragePermission": { "description": "Storage permission step title" }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", + "setupStoragePermissionSubtitle": "Necesario para guardar los archivos descargados", "@setupStoragePermissionSubtitle": { "description": "Explanation for storage permission" }, - "setupStoragePermissionGranted": "Permission granted", + "setupStoragePermissionGranted": "Permiso aprobado", "@setupStoragePermissionGranted": { "description": "Status when permission granted" }, - "setupStoragePermissionDenied": "Permission denied", + "setupStoragePermissionDenied": "Permiso denegado", "@setupStoragePermissionDenied": { "description": "Status when permission denied" }, - "setupGrantPermission": "Grant Permission", + "setupGrantPermission": "Conceder permiso", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", + "setupDownloadLocation": "Ubicación de descarga", "@setupDownloadLocation": { "description": "Download folder step title" }, - "setupChooseFolder": "Choose Folder", + "setupChooseFolder": "Seleccionar Carpeta", "@setupChooseFolder": { "description": "Button to pick folder" }, - "setupContinue": "Continue", + "setupContinue": "Continuar", "@setupContinue": { "description": "Continue to next step button" }, - "setupSkip": "Skip for now", + "setupSkip": "Omitir por ahora", "@setupSkip": { "description": "Skip current step button" }, - "setupStorageAccessRequired": "Storage Access Required", + "setupStorageAccessRequired": "Acceso al almacenamiento requerido", "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", + "setupStorageAccessMessage": "SpotiFLAC necesita permiso de \"Todos los archivos de acceso\" para guardar los archivos de música en la carpeta elegida.", "@setupStorageAccessMessage": { "description": "Explanation for storage access" }, - "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", + "setupStorageAccessMessageAndroid11": "Android 11+ requiere permiso \"Todos los archivos de acceso\" para guardar los archivos en la carpeta de descargas elegida.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" }, - "setupOpenSettings": "Open Settings", + "setupOpenSettings": "Abrir ajustes", "@setupOpenSettings": { "description": "Button to open system settings" }, - "setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.", + "setupPermissionDeniedMessage": "Permiso denegado. Por favor, conceda todos los permisos para continuar.", "@setupPermissionDeniedMessage": { "description": "Error when permission denied" }, - "setupPermissionRequired": "{permissionType} Permission Required", + "setupPermissionRequired": "Permiso de {permissionType} requerido", "@setupPermissionRequired": { "description": "Generic permission required title", "placeholders": { @@ -782,7 +782,7 @@ } } }, - "setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.", + "setupPermissionRequiredMessage": "Se requiere un permiso {permissionType} para la mejor experiencia. Puedes cambiar esto más tarde en ajustes.", "@setupPermissionRequiredMessage": { "description": "Generic permission required message", "placeholders": { @@ -791,63 +791,63 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", + "setupSelectDownloadFolder": "Seleccionar carpeta de descarga", "@setupSelectDownloadFolder": { "description": "Folder selection step title" }, - "setupUseDefaultFolder": "Use Default Folder?", + "setupUseDefaultFolder": "¿Usar carpeta por defecto?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" }, - "setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?", + "setupNoFolderSelected": "No se ha seleccionado ninguna carpeta. ¿Desea utilizar la carpeta por defecto?", "@setupNoFolderSelected": { "description": "Prompt when no folder selected" }, - "setupUseDefault": "Use Default", + "setupUseDefault": "Usar por defecto", "@setupUseDefault": { "description": "Button to use default folder" }, - "setupDownloadLocationTitle": "Download Location", + "setupDownloadLocationTitle": "Ubicación de descarga", "@setupDownloadLocationTitle": { "description": "Download location dialog title" }, - "setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.", + "setupDownloadLocationIosMessage": "En iOS, las descargas se guardan en la carpeta de documentos de la aplicación. Puede acceder a ellas desde la aplicación Archivos.", "@setupDownloadLocationIosMessage": { "description": "iOS-specific folder info" }, - "setupAppDocumentsFolder": "App Documents Folder", + "setupAppDocumentsFolder": "Carpeta de documentos de App", "@setupAppDocumentsFolder": { "description": "iOS documents folder option" }, - "setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app", + "setupAppDocumentsFolderSubtitle": "Recomendado - accesible desde la aplicación Archivos", "@setupAppDocumentsFolderSubtitle": { "description": "Subtitle for documents folder" }, - "setupChooseFromFiles": "Choose from Files", + "setupChooseFromFiles": "Elegir de archivos", "@setupChooseFromFiles": { "description": "iOS file picker option" }, - "setupChooseFromFilesSubtitle": "Select iCloud or other location", + "setupChooseFromFilesSubtitle": "Seleccione iCloud u otra ubicación", "@setupChooseFromFilesSubtitle": { "description": "Subtitle for file picker" }, - "setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", + "setupIosEmptyFolderWarning": "Limitación de iOS: No se pueden seleccionar carpetas vacías. Elige una carpeta con al menos un archivo.", "@setupIosEmptyFolderWarning": { "description": "iOS folder selection warning" }, - "setupDownloadInFlac": "Download Spotify tracks in FLAC", + "setupDownloadInFlac": "Descargar pistas de Spotify en FLAC", "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", + "setupStepStorage": "Almacenamiento", "@setupStepStorage": { "description": "Setup step indicator - storage" }, - "setupStepNotification": "Notification", + "setupStepNotification": "Notificación", "@setupStepNotification": { "description": "Setup step indicator - notification" }, - "setupStepFolder": "Folder", + "setupStepFolder": "Carpeta", "@setupStepFolder": { "description": "Setup step indicator - folder" }, @@ -855,155 +855,155 @@ "@setupStepSpotify": { "description": "Setup step indicator - Spotify API" }, - "setupStepPermission": "Permission", + "setupStepPermission": "Permiso", "@setupStepPermission": { "description": "Setup step indicator - permission" }, - "setupStorageGranted": "Storage Permission Granted!", + "setupStorageGranted": "¡Permiso de almacenamiento concedido!", "@setupStorageGranted": { "description": "Success message for storage permission" }, - "setupStorageRequired": "Storage Permission Required", + "setupStorageRequired": "Permiso de almacenamiento requerido", "@setupStorageRequired": { "description": "Title when storage permission needed" }, - "setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", + "setupStorageDescription": "SpotiFLAC necesita permiso de almacenamiento para guardar sus archivos de música descargados.", "@setupStorageDescription": { "description": "Explanation for storage permission" }, - "setupNotificationGranted": "Notification Permission Granted!", + "setupNotificationGranted": "¡Acceso a las notificaciones permitido!", "@setupNotificationGranted": { "description": "Success message for notification permission" }, - "setupNotificationEnable": "Enable Notifications", + "setupNotificationEnable": "Activar notificaciones", "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", + "setupNotificationDescription": "Recibe notificaciones cuando las descargas completen o requieran atención.", "@setupNotificationDescription": { "description": "Explanation for notifications" }, - "setupFolderSelected": "Download Folder Selected!", + "setupFolderSelected": "¡Carpeta de descarga seleccionada!", "@setupFolderSelected": { "description": "Success message for folder selection" }, - "setupFolderChoose": "Choose Download Folder", + "setupFolderChoose": "Cambiar carpeta de descargas", "@setupFolderChoose": { "description": "Button to choose folder" }, - "setupFolderDescription": "Select a folder where your downloaded music will be saved.", + "setupFolderDescription": "Seleccione una carpeta donde se guardará la música descargada.", "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", + "setupChangeFolder": "Cambiar carpeta", "@setupChangeFolder": { "description": "Button to change selected folder" }, - "setupSelectFolder": "Select Folder", + "setupSelectFolder": "Seleccionar Carpeta", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", + "setupSpotifyApiOptional": "API de Spotify (opcional)", "@setupSpotifyApiOptional": { "description": "Spotify API step title" }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", + "setupSpotifyApiDescription": "Añade tus credenciales de la API de Spotify para mejores resultados de búsqueda y acceso al contenido exclusivo de Spotify.", "@setupSpotifyApiDescription": { "description": "Explanation for Spotify API" }, - "setupUseSpotifyApi": "Use Spotify API", + "setupUseSpotifyApi": "Usar API de Spotify", "@setupUseSpotifyApi": { "description": "Toggle to enable Spotify API" }, - "setupEnterCredentialsBelow": "Enter your credentials below", + "setupEnterCredentialsBelow": "Ingresa tus credenciales a continuación", "@setupEnterCredentialsBelow": { "description": "Prompt to enter credentials" }, - "setupUsingDeezer": "Using Deezer (no account needed)", + "setupUsingDeezer": "Usando Deezer (no se necesita cuenta)", "@setupUsingDeezer": { "description": "Status when using Deezer" }, - "setupEnterClientId": "Enter Spotify Client ID", + "setupEnterClientId": "Introduzca el ID de cliente de Spotify", "@setupEnterClientId": { "description": "Placeholder for client ID field" }, - "setupEnterClientSecret": "Enter Spotify Client Secret", + "setupEnterClientSecret": "Ingresa el Client Secret de Spotify", "@setupEnterClientSecret": { "description": "Placeholder for client secret field" }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", + "setupGetFreeCredentials": "Obtén tus credenciales gratuitas de la API desde el Spotify Developer Dashboard.", "@setupGetFreeCredentials": { "description": "Info about getting Spotify credentials" }, - "setupEnableNotifications": "Enable Notifications", + "setupEnableNotifications": "Activar notificaciones", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", + "setupProceedToNextStep": "Ahora puedes continuar con el siguiente paso.", "@setupProceedToNextStep": { "description": "Message after completing a step" }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", + "setupNotificationProgressDescription": "Recibirás notificaciones de progreso de descargas.", "@setupNotificationProgressDescription": { "description": "Info about notification usage" }, - "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", + "setupNotificationBackgroundDescription": "Recibe notificaciones sobre el progreso de la descarga y la finalización. Esto te ayuda a rastrear las descargas cuando la aplicación está en segundo plano.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" }, - "setupSkipForNow": "Skip for now", + "setupSkipForNow": "Omitir por ahora", "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", + "setupBack": "Atrás", "@setupBack": { "description": "Back button text" }, - "setupNext": "Next", + "setupNext": "Siguiente", "@setupNext": { "description": "Next button text" }, - "setupGetStarted": "Get Started", + "setupGetStarted": "Empezar", "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", + "setupSkipAndStart": "Saltar y empezar", "@setupSkipAndStart": { "description": "Skip setup and start app" }, - "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", + "setupAllowAccessToManageFiles": "Por favor, activa \"Permitir el acceso para gestionar todos los archivos\" en la siguiente pantalla.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", + "setupGetCredentialsFromSpotify": "Obtener credenciales de developer.spotify.com", "@setupGetCredentialsFromSpotify": { "description": "Link text for Spotify developer portal" }, - "dialogCancel": "Cancel", + "dialogCancel": "Cancelar", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", + "dialogOk": "Aceptar", "@dialogOk": { "description": "Dialog button - confirm/acknowledge" }, - "dialogSave": "Save", + "dialogSave": "Guardar", "@dialogSave": { "description": "Dialog button - save changes" }, - "dialogDelete": "Delete", + "dialogDelete": "Eliminar", "@dialogDelete": { "description": "Dialog button - delete item" }, - "dialogRetry": "Retry", + "dialogRetry": "Volver a intentar", "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", + "dialogClose": "Cerrar", "@dialogClose": { "description": "Dialog button - close dialog" }, - "dialogYes": "Yes", + "dialogYes": "Sí", "@dialogYes": { "description": "Dialog button - confirm yes" }, @@ -1011,51 +1011,51 @@ "@dialogNo": { "description": "Dialog button - confirm no" }, - "dialogClear": "Clear", + "dialogClear": "Borrar", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", + "dialogConfirm": "Confirmar", "@dialogConfirm": { "description": "Dialog button - confirm action" }, - "dialogDone": "Done", + "dialogDone": "Hecho", "@dialogDone": { "description": "Dialog button - action completed" }, - "dialogImport": "Import", + "dialogImport": "Importar", "@dialogImport": { "description": "Dialog button - import data" }, - "dialogDiscard": "Discard", + "dialogDiscard": "Descartar", "@dialogDiscard": { "description": "Dialog button - discard changes" }, - "dialogRemove": "Remove", + "dialogRemove": "Eliminar", "@dialogRemove": { "description": "Dialog button - remove item" }, - "dialogUninstall": "Uninstall", + "dialogUninstall": "Desinstalar", "@dialogUninstall": { "description": "Dialog button - uninstall extension" }, - "dialogDiscardChanges": "Discard Changes?", + "dialogDiscardChanges": "¿Descartar cambios?", "@dialogDiscardChanges": { "description": "Dialog title - unsaved changes warning" }, - "dialogUnsavedChanges": "You have unsaved changes. Do you want to discard them?", + "dialogUnsavedChanges": "Tienes cambios sin guardar. ¿Quieres descartarlos?", "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", + "dialogDownloadFailed": "Descarga fallida", "@dialogDownloadFailed": { "description": "Dialog title - download error" }, - "dialogTrackLabel": "Track:", + "dialogTrackLabel": "Pista:", "@dialogTrackLabel": { "description": "Label for track name in error dialog" }, - "dialogArtistLabel": "Artist:", + "dialogArtistLabel": "Artista:", "@dialogArtistLabel": { "description": "Label for artist name in error dialog" }, @@ -1063,31 +1063,31 @@ "@dialogErrorLabel": { "description": "Label for error message" }, - "dialogClearAll": "Clear All", + "dialogClearAll": "Eliminar todo", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", + "dialogClearAllDownloads": "¿Estás seguro de que quieres borrar todas las descargas?", "@dialogClearAllDownloads": { "description": "Dialog message - clear downloads confirmation" }, - "dialogRemoveFromDevice": "Remove from device?", + "dialogRemoveFromDevice": "¿Eliminar del dispositivo?", "@dialogRemoveFromDevice": { "description": "Dialog title - delete file confirmation" }, - "dialogRemoveExtension": "Remove Extension", + "dialogRemoveExtension": "Eliminar extensión", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" }, - "dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.", + "dialogRemoveExtensionMessage": "¿Estás seguro de que quieres eliminar esta extensión? Esto no se puede deshacer.", "@dialogRemoveExtensionMessage": { "description": "Dialog message - uninstall confirmation" }, - "dialogUninstallExtension": "Uninstall Extension?", + "dialogUninstallExtension": "¿Desinstalar extensión?", "@dialogUninstallExtension": { "description": "Dialog title - uninstall extension" }, - "dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?", + "dialogUninstallExtensionMessage": "¿Estás seguro de que quieres eliminar {extensionName}?", "@dialogUninstallExtensionMessage": { "description": "Dialog message - uninstall specific extension", "placeholders": { @@ -1096,19 +1096,19 @@ } } }, - "dialogClearHistoryTitle": "Clear History", + "dialogClearHistoryTitle": "Borrar historial", "@dialogClearHistoryTitle": { "description": "Dialog title - clear download history" }, - "dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.", + "dialogClearHistoryMessage": "¿Estás seguro de que quieres borrar todo el historial de descargas? Esta acción no se puede deshacer.", "@dialogClearHistoryMessage": { "description": "Dialog message - clear history confirmation" }, - "dialogDeleteSelectedTitle": "Delete Selected", + "dialogDeleteSelectedTitle": "Borrar Seleccionados", "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.", + "dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -1117,11 +1117,11 @@ } } }, - "dialogImportPlaylistTitle": "Import Playlist", + "dialogImportPlaylistTitle": "Importar lista de reproducción", "@dialogImportPlaylistTitle": { "description": "Dialog title - import CSV playlist" }, - "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", + "dialogImportPlaylistMessage": "Se han encontrado pistas {count} en CSV. ¿Añadirlas para descargar la cola?", "@dialogImportPlaylistMessage": { "description": "Dialog message - import playlist confirmation", "placeholders": { @@ -1130,7 +1130,7 @@ } } }, - "snackbarAddedToQueue": "Added \"{trackName}\" to queue", + "snackbarAddedToQueue": "Añadido \"{trackName}\" a la cola", "@snackbarAddedToQueue": { "description": "Snackbar - track added to download queue", "placeholders": { @@ -1139,7 +1139,7 @@ } } }, - "snackbarAddedTracksToQueue": "Added {count} tracks to queue", + "snackbarAddedTracksToQueue": "Añadidas pistas {count} a la cola", "@snackbarAddedTracksToQueue": { "description": "Snackbar - multiple tracks added to queue", "placeholders": { @@ -1148,7 +1148,7 @@ } } }, - "snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded", + "snackbarAlreadyDownloaded": "\"{trackName}\" ya descargado", "@snackbarAlreadyDownloaded": { "description": "Snackbar - track already exists", "placeholders": { @@ -1157,19 +1157,19 @@ } } }, - "snackbarHistoryCleared": "History cleared", + "snackbarHistoryCleared": "Historial borrado", "@snackbarHistoryCleared": { "description": "Snackbar - history deleted" }, - "snackbarCredentialsSaved": "Credentials saved", + "snackbarCredentialsSaved": "Credenciales guardadas", "@snackbarCredentialsSaved": { "description": "Snackbar - Spotify credentials saved" }, - "snackbarCredentialsCleared": "Credentials cleared", + "snackbarCredentialsCleared": "Credenciales borradas", "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}", + "snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -1178,7 +1178,7 @@ } } }, - "snackbarCannotOpenFile": "Cannot open file: {error}", + "snackbarCannotOpenFile": "No se puede abrir el archivo: {error}", "@snackbarCannotOpenFile": { "description": "Snackbar - file open error", "placeholders": { @@ -1187,15 +1187,15 @@ } } }, - "snackbarFillAllFields": "Please fill all fields", + "snackbarFillAllFields": "Por favor, completa todos los campos", "@snackbarFillAllFields": { "description": "Snackbar - validation error" }, - "snackbarViewQueue": "View Queue", + "snackbarViewQueue": "Ver cola", "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", + "snackbarFailedToLoad": "Error al cargar: {error}", "@snackbarFailedToLoad": { "description": "Snackbar - loading error", "placeholders": { @@ -1204,7 +1204,7 @@ } } }, - "snackbarUrlCopied": "{platform} URL copied to clipboard", + "snackbarUrlCopied": "URL {platform} copiada al portapapeles", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", "placeholders": { @@ -1214,23 +1214,23 @@ } } }, - "snackbarFileNotFound": "File not found", + "snackbarFileNotFound": "Archivo no encontrado", "@snackbarFileNotFound": { "description": "Snackbar - file doesn't exist" }, - "snackbarSelectExtFile": "Please select a .spotiflac-ext file", + "snackbarSelectExtFile": "Por favor, seleccione un archivo .spotiflac-ext", "@snackbarSelectExtFile": { "description": "Snackbar - wrong file type selected" }, - "snackbarProviderPrioritySaved": "Provider priority saved", + "snackbarProviderPrioritySaved": "Prioridad de proveedor guardada", "@snackbarProviderPrioritySaved": { "description": "Snackbar - provider order saved" }, - "snackbarMetadataProviderSaved": "Metadata provider priority saved", + "snackbarMetadataProviderSaved": "Prioridad de proveedor de metadatos guardada", "@snackbarMetadataProviderSaved": { "description": "Snackbar - metadata provider order saved" }, - "snackbarExtensionInstalled": "{extensionName} installed.", + "snackbarExtensionInstalled": "{extensionName} instalado.", "@snackbarExtensionInstalled": { "description": "Snackbar - extension installed successfully", "placeholders": { @@ -1239,7 +1239,7 @@ } } }, - "snackbarExtensionUpdated": "{extensionName} updated.", + "snackbarExtensionUpdated": "{extensionName} actualizada.", "@snackbarExtensionUpdated": { "description": "Snackbar - extension updated successfully", "placeholders": { @@ -1248,23 +1248,23 @@ } } }, - "snackbarFailedToInstall": "Failed to install extension", + "snackbarFailedToInstall": "Fallo al instalar la extensión", "@snackbarFailedToInstall": { "description": "Snackbar - extension install error" }, - "snackbarFailedToUpdate": "Failed to update extension", + "snackbarFailedToUpdate": "Error al actualizar la extensión", "@snackbarFailedToUpdate": { "description": "Snackbar - extension update error" }, - "errorRateLimited": "Rate Limited", + "errorRateLimited": "Límite Excedido", "@errorRateLimited": { "description": "Error title - too many requests" }, - "errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", + "errorRateLimitedMessage": "Demasiadas solicitudes. Por favor, espere un momento antes de buscar de nuevo.", "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", + "errorFailedToLoad": "Error al cargar {item}", "@errorFailedToLoad": { "description": "Error message - loading failed", "placeholders": { @@ -1274,11 +1274,11 @@ } } }, - "errorNoTracksFound": "No tracks found", + "errorNoTracksFound": "No se encontraron pistas", "@errorNoTracksFound": { "description": "Error - search returned no results" }, - "errorMissingExtensionSource": "Cannot load {item}: missing extension source", + "errorMissingExtensionSource": "No se puede cargar {item}: falta una fuente de extensión", "@errorMissingExtensionSource": { "description": "Error - extension source not available", "placeholders": { @@ -1287,79 +1287,79 @@ } } }, - "statusQueued": "Queued", + "statusQueued": "En cola", "@statusQueued": { "description": "Download status - waiting in queue" }, - "statusDownloading": "Downloading", + "statusDownloading": "Descargando", "@statusDownloading": { "description": "Download status - in progress" }, - "statusFinalizing": "Finalizing", + "statusFinalizing": "Finalizando", "@statusFinalizing": { "description": "Download status - writing metadata" }, - "statusCompleted": "Completed", + "statusCompleted": "Completado", "@statusCompleted": { "description": "Download status - finished" }, - "statusFailed": "Failed", + "statusFailed": "Error", "@statusFailed": { "description": "Download status - error occurred" }, - "statusSkipped": "Skipped", + "statusSkipped": "Omitido", "@statusSkipped": { "description": "Download status - already exists" }, - "statusPaused": "Paused", + "statusPaused": "Pausado", "@statusPaused": { "description": "Download status - paused" }, - "actionPause": "Pause", + "actionPause": "Pausar", "@actionPause": { "description": "Action button - pause download" }, - "actionResume": "Resume", + "actionResume": "Reanudar", "@actionResume": { "description": "Action button - resume download" }, - "actionCancel": "Cancel", + "actionCancel": "Cancelar", "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", + "actionStop": "Detener", "@actionStop": { "description": "Action button - stop operation" }, - "actionSelect": "Select", + "actionSelect": "Seleccionar", "@actionSelect": { "description": "Action button - enter selection mode" }, - "actionSelectAll": "Select All", + "actionSelectAll": "Seleccionar Todo", "@actionSelectAll": { "description": "Action button - select all items" }, - "actionDeselect": "Deselect", + "actionDeselect": "Deseleccionar", "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", + "actionPaste": "Pegar", "@actionPaste": { "description": "Action button - paste from clipboard" }, - "actionImportCsv": "Import CSV", + "actionImportCsv": "Importar CSV", "@actionImportCsv": { "description": "Action button - import CSV file" }, - "actionRemoveCredentials": "Remove Credentials", + "actionRemoveCredentials": "Eliminar credenciales", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" }, - "actionSaveCredentials": "Save Credentials", + "actionSaveCredentials": "Guardar credenciales", "@actionSaveCredentials": { "description": "Action button - save Spotify credentials" }, - "selectionSelected": "{count} selected", + "selectionSelected": "{count} seleccionado", "@selectionSelected": { "description": "Selection count indicator", "placeholders": { @@ -1368,15 +1368,15 @@ } } }, - "selectionAllSelected": "All tracks selected", + "selectionAllSelected": "Todas las pistas seleccionadas", "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", + "selectionTapToSelect": "Toca las pistas para seleccionar", "@selectionTapToSelect": { "description": "Hint - how to select items" }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", + "selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}", "@selectionDeleteTracks": { "description": "Delete button with count", "placeholders": { @@ -1385,11 +1385,11 @@ } } }, - "selectionSelectToDelete": "Select tracks to delete", + "selectionSelectToDelete": "Seleccionar pistas a eliminar", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" }, - "progressFetchingMetadata": "Fetching metadata... {current}/{total}", + "progressFetchingMetadata": "Obteniendo metadatos... {current}/{total}", "@progressFetchingMetadata": { "description": "Progress indicator - loading track info", "placeholders": { @@ -1401,59 +1401,59 @@ } } }, - "progressReadingCsv": "Reading CSV...", + "progressReadingCsv": "Leyendo CSV...", "@progressReadingCsv": { "description": "Progress indicator - parsing CSV file" }, - "searchSongs": "Songs", + "searchSongs": "Canciones", "@searchSongs": { "description": "Search result category - songs" }, - "searchArtists": "Artists", + "searchArtists": "Artistas", "@searchArtists": { "description": "Search result category - artists" }, - "searchAlbums": "Albums", + "searchAlbums": "Álbumes", "@searchAlbums": { "description": "Search result category - albums" }, - "searchPlaylists": "Playlists", + "searchPlaylists": "Listas de reproducción", "@searchPlaylists": { "description": "Search result category - playlists" }, - "tooltipPlay": "Play", + "tooltipPlay": "Reproducir", "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", + "tooltipCancel": "Cancelar", "@tooltipCancel": { "description": "Tooltip - cancel button" }, - "tooltipStop": "Stop", + "tooltipStop": "Detener", "@tooltipStop": { "description": "Tooltip - stop button" }, - "tooltipRetry": "Retry", + "tooltipRetry": "Volver a intentar", "@tooltipRetry": { "description": "Tooltip - retry button" }, - "tooltipRemove": "Remove", + "tooltipRemove": "Eliminar", "@tooltipRemove": { "description": "Tooltip - remove button" }, - "tooltipClear": "Clear", + "tooltipClear": "Borrar", "@tooltipClear": { "description": "Tooltip - clear button" }, - "tooltipPaste": "Paste", + "tooltipPaste": "Pegar", "@tooltipPaste": { "description": "Tooltip - paste button" }, - "filenameFormat": "Filename Format", + "filenameFormat": "Formato del nombre del archivo", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", + "filenameFormatPreview": "Vista previa: {preview}", "@filenameFormatPreview": { "description": "Preview of filename pattern", "placeholders": { @@ -1462,7 +1462,7 @@ } } }, - "filenameAvailablePlaceholders": "Available placeholders:", + "filenameAvailablePlaceholders": "Marcadores disponibles:", "@filenameAvailablePlaceholders": { "description": "Label for placeholder list" }, @@ -1470,51 +1470,51 @@ "@filenameHint": { "description": "Default filename format hint" }, - "folderOrganization": "Folder Organization", + "folderOrganization": "Organización de carpetas", "@folderOrganization": { "description": "Setting title - folder structure" }, - "folderOrganizationNone": "No organization", + "folderOrganizationNone": "Ninguna organización", "@folderOrganizationNone": { "description": "Folder option - flat structure" }, - "folderOrganizationByArtist": "By Artist", + "folderOrganizationByArtist": "Por Artista", "@folderOrganizationByArtist": { "description": "Folder option - artist folders" }, - "folderOrganizationByAlbum": "By Album", + "folderOrganizationByAlbum": "Por Álbum", "@folderOrganizationByAlbum": { "description": "Folder option - album folders" }, - "folderOrganizationByArtistAlbum": "Artist/Album", + "folderOrganizationByArtistAlbum": "Artista/Álbum", "@folderOrganizationByArtistAlbum": { "description": "Folder option - nested folders" }, - "folderOrganizationDescription": "Organize downloaded files into folders", + "folderOrganizationDescription": "Organizar los archivos descargados en carpetas", "@folderOrganizationDescription": { "description": "Folder organization sheet description" }, - "folderOrganizationNoneSubtitle": "All files in download folder", + "folderOrganizationNoneSubtitle": "Todos los archivos de la carpeta de descargas", "@folderOrganizationNoneSubtitle": { "description": "Subtitle for no organization option" }, - "folderOrganizationByArtistSubtitle": "Separate folder for each artist", + "folderOrganizationByArtistSubtitle": "Carpeta separada para cada artista", "@folderOrganizationByArtistSubtitle": { "description": "Subtitle for artist folder option" }, - "folderOrganizationByAlbumSubtitle": "Separate folder for each album", + "folderOrganizationByAlbumSubtitle": "Carpeta separada para cada artista", "@folderOrganizationByAlbumSubtitle": { "description": "Subtitle for album folder option" }, - "folderOrganizationByArtistAlbumSubtitle": "Nested folders for artist and album", + "folderOrganizationByArtistAlbumSubtitle": "Carpetas organizadas por artista y álbum", "@folderOrganizationByArtistAlbumSubtitle": { "description": "Subtitle for nested folder option" }, - "updateAvailable": "Update Available", + "updateAvailable": "Actualización Disponible", "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", + "updateNewVersion": "Versión {version} está disponible", "@updateNewVersion": { "description": "Update available message", "placeholders": { @@ -1523,231 +1523,231 @@ } } }, - "updateDownload": "Download", + "updateDownload": "Descargar", "@updateDownload": { "description": "Update button - download update" }, - "updateLater": "Later", + "updateLater": "Más tarde", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", + "updateChangelog": "Historial de cambios", "@updateChangelog": { "description": "Link to changelog" }, - "updateStartingDownload": "Starting download...", + "updateStartingDownload": "Iniciando descarga...", "@updateStartingDownload": { "description": "Update status - initializing" }, - "updateDownloadFailed": "Download failed", + "updateDownloadFailed": "Descarga fallida", "@updateDownloadFailed": { "description": "Update error title" }, - "updateFailedMessage": "Failed to download update", + "updateFailedMessage": "Error al descargar la actualización", "@updateFailedMessage": { "description": "Update error message" }, - "updateNewVersionReady": "A new version is ready", + "updateNewVersionReady": "Una nueva versión está lista", "@updateNewVersionReady": { "description": "Update subtitle" }, - "updateCurrent": "Current", + "updateCurrent": "Actual", "@updateCurrent": { "description": "Label for current version" }, - "updateNew": "New", + "updateNew": "Nuevo", "@updateNew": { "description": "Label for new version" }, - "updateDownloading": "Downloading...", + "updateDownloading": "Descargando...", "@updateDownloading": { "description": "Update status - downloading" }, - "updateWhatsNew": "What's New", + "updateWhatsNew": "Novedades", "@updateWhatsNew": { "description": "Changelog section title" }, - "updateDownloadInstall": "Download & Install", + "updateDownloadInstall": "Descargar & Instalar", "@updateDownloadInstall": { "description": "Update button - download and install" }, - "updateDontRemind": "Don't remind", + "updateDontRemind": "No recordar", "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", + "providerPriority": "Prioridad del proveedor", "@providerPriority": { "description": "Setting title - download provider order" }, - "providerPrioritySubtitle": "Drag to reorder download providers", + "providerPrioritySubtitle": "Arrastre para reordenar los proveedores de descarga", "@providerPrioritySubtitle": { "description": "Subtitle for provider priority" }, - "providerPriorityTitle": "Provider Priority", + "providerPriorityTitle": "Prioridad del proveedor", "@providerPriorityTitle": { "description": "Provider priority page title" }, - "providerPriorityDescription": "Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.", + "providerPriorityDescription": "Arrastra para reordenar los proveedores de descarga. La aplicación intentará usar los proveedores de arriba hacia abajo al descargar las pistas.", "@providerPriorityDescription": { "description": "Provider priority page description" }, - "providerPriorityInfo": "If a track is not available on the first provider, the app will automatically try the next one.", + "providerPriorityInfo": "Si una pista no está disponible en el primer proveedor, la aplicación intentará automáticamente el siguiente.", "@providerPriorityInfo": { "description": "Info tip about fallback behavior" }, - "providerBuiltIn": "Built-in", + "providerBuiltIn": "Integrado", "@providerBuiltIn": { "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" }, - "providerExtension": "Extension", + "providerExtension": "Extensión", "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", + "metadataProviderPriority": "Prioridad del proveedor de metadatos", "@metadataProviderPriority": { "description": "Setting title - metadata provider order" }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", + "metadataProviderPrioritySubtitle": "Orden usado al recuperar metadatos de la pista", "@metadataProviderPrioritySubtitle": { "description": "Subtitle for metadata priority" }, - "metadataProviderPriorityTitle": "Metadata Priority", + "metadataProviderPriorityTitle": "Prioridad de los metadatos", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" }, - "metadataProviderPriorityDescription": "Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.", + "metadataProviderPriorityDescription": "Arrastra para reordenar los proveedores de metadatos. La aplicación probará los proveedores de arriba hacia abajo al buscar pistas y obtener los metadatos.", "@metadataProviderPriorityDescription": { "description": "Metadata priority page description" }, - "metadataProviderPriorityInfo": "Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.", + "metadataProviderPriorityInfo": "Deezer no tiene límites de tasa y se recomienda como principal. Spotify puede valorar el límite después de muchas solicitudes.", "@metadataProviderPriorityInfo": { "description": "Info tip about rate limits" }, - "metadataNoRateLimits": "No rate limits", + "metadataNoRateLimits": "Sin límites de tasa", "@metadataNoRateLimits": { "description": "Deezer provider description" }, - "metadataMayRateLimit": "May rate limit", + "metadataMayRateLimit": "Sin límites de tasa", "@metadataMayRateLimit": { "description": "Spotify provider description" }, - "logTitle": "Logs", + "logTitle": "Registros", "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", + "logCopy": "Copiar Registros", "@logCopy": { "description": "Action - copy logs to clipboard" }, - "logClear": "Clear Logs", + "logClear": "Limpiar registros", "@logClear": { "description": "Action - delete all logs" }, - "logShare": "Share Logs", + "logShare": "Compartir Registros", "@logShare": { "description": "Action - share logs file" }, - "logEmpty": "No logs yet", + "logEmpty": "No hay registros aún", "@logEmpty": { "description": "Empty state title" }, - "logCopied": "Logs copied to clipboard", + "logCopied": "Registros copiados al portapapeles", "@logCopied": { "description": "Snackbar - logs copied" }, - "logSearchHint": "Search logs...", + "logSearchHint": "Buscar registros...", "@logSearchHint": { "description": "Log search placeholder" }, - "logFilterLevel": "Level", + "logFilterLevel": "Nivel", "@logFilterLevel": { "description": "Filter by log level" }, - "logFilterSection": "Filter", + "logFilterSection": "Filtrar", "@logFilterSection": { "description": "Filter section title" }, - "logShareLogs": "Share logs", + "logShareLogs": "Compartir registros", "@logShareLogs": { "description": "Share button tooltip" }, - "logClearLogs": "Clear logs", + "logClearLogs": "Borrar registros", "@logClearLogs": { "description": "Clear button tooltip" }, - "logClearLogsTitle": "Clear Logs", + "logClearLogsTitle": "Limpiar registros", "@logClearLogsTitle": { "description": "Clear logs dialog title" }, - "logClearLogsMessage": "Are you sure you want to clear all logs?", + "logClearLogsMessage": "¿Estás seguro que deseas limpiar todos los registros?", "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", + "logIspBlocking": "BLOQUEO POR EL ISP DETECTADO", "@logIspBlocking": { "description": "Error category - ISP blocking" }, - "logRateLimited": "RATE LIMITED", + "logRateLimited": "TASA LIMITADA", "@logRateLimited": { "description": "Error category - rate limiting" }, - "logNetworkError": "NETWORK ERROR", + "logNetworkError": "ERROR DE RED", "@logNetworkError": { "description": "Error category - network issues" }, - "logTrackNotFound": "TRACK NOT FOUND", + "logTrackNotFound": "PISTA NO ENCONTRADA", "@logTrackNotFound": { "description": "Error category - missing tracks" }, - "logFilterBySeverity": "Filter logs by severity", + "logFilterBySeverity": "Filtrar los registros por gravedad", "@logFilterBySeverity": { "description": "Filter dialog title" }, - "logNoLogsYet": "No logs yet", + "logNoLogsYet": "No hay registros aún", "@logNoLogsYet": { "description": "Empty state title" }, - "logNoLogsYetSubtitle": "Logs will appear here as you use the app", + "logNoLogsYetSubtitle": "Los registros aparecerán aquí mientras usas la aplicación", "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", + "logIssueSummary": "Resumen de Incidencias", "@logIssueSummary": { "description": "Section header for error summary" }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", + "logIspBlockingDescription": "Tu ISP puede estar bloqueando el acceso a los servicios de descarga", "@logIspBlockingDescription": { "description": "ISP blocking explanation" }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", + "logIspBlockingSuggestion": "Intente usar una VPN o cambie el DNS a 1.1.1.1 o 8.8.8.8", "@logIspBlockingSuggestion": { "description": "ISP blocking fix suggestion" }, - "logRateLimitedDescription": "Too many requests to the service", + "logRateLimitedDescription": "Demasiadas solicitudes al servicio", "@logRateLimitedDescription": { "description": "Rate limit explanation" }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", + "logRateLimitedSuggestion": "Espere unos minutos antes de volver a intentarlo", "@logRateLimitedSuggestion": { "description": "Rate limit fix suggestion" }, - "logNetworkErrorDescription": "Connection issues detected", + "logNetworkErrorDescription": "Problemas de conexión detectados", "@logNetworkErrorDescription": { "description": "Network error explanation" }, - "logNetworkErrorSuggestion": "Check your internet connection", + "logNetworkErrorSuggestion": "Comprueba tu conexión a internet", "@logNetworkErrorSuggestion": { "description": "Network error fix suggestion" }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", + "logTrackNotFoundDescription": "No se pudieron encontrar algunas pistas en los servicios de descarga", "@logTrackNotFoundDescription": { "description": "Track not found explanation" }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", + "logTrackNotFoundSuggestion": "La pista puede no estar disponible en calidad sin pérdida", "@logTrackNotFoundSuggestion": { "description": "Track not found explanation" }, - "logTotalErrors": "Total errors: {count}", + "logTotalErrors": "Total de errores: {count}", "@logTotalErrors": { "description": "Error count display", "placeholders": { @@ -1756,7 +1756,7 @@ } } }, - "logAffected": "Affected: {domains}", + "logAffected": "Afectado: {domains}", "@logAffected": { "description": "Affected domains display", "placeholders": { @@ -1765,7 +1765,7 @@ } } }, - "logEntriesFiltered": "Entries ({count} filtered)", + "logEntriesFiltered": "Entradas ({count} filtradas)", "@logEntriesFiltered": { "description": "Log count with filter active", "placeholders": { @@ -1774,7 +1774,7 @@ } } }, - "logEntries": "Entries ({count})", + "logEntries": "Entradas ({count})", "@logEntries": { "description": "Total log count", "placeholders": { @@ -1783,19 +1783,19 @@ } } }, - "credentialsTitle": "Spotify Credentials", + "credentialsTitle": "Credenciales de Spotify", "@credentialsTitle": { "description": "Credentials dialog title" }, - "credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.", + "credentialsDescription": "Introduzca su ID de cliente y secreto para utilizar su propia cuota de aplicación de Spotify.", "@credentialsDescription": { "description": "Credentials dialog explanation" }, - "credentialsClientId": "Client ID", + "credentialsClientId": "ID del cliente", "@credentialsClientId": { "description": "Client ID field label - DO NOT TRANSLATE" }, - "credentialsClientIdHint": "Paste Client ID", + "credentialsClientIdHint": "Pegar ID de cliente", "@credentialsClientIdHint": { "description": "Client ID placeholder" }, @@ -1803,111 +1803,111 @@ "@credentialsClientSecret": { "description": "Client Secret field label - DO NOT TRANSLATE" }, - "credentialsClientSecretHint": "Paste Client Secret", + "credentialsClientSecretHint": "Pegar Client Secret", "@credentialsClientSecretHint": { "description": "Client Secret placeholder" }, - "channelStable": "Stable", + "channelStable": "Estable", "@channelStable": { "description": "Update channel - stable releases" }, - "channelPreview": "Preview", + "channelPreview": "Vista previa", "@channelPreview": { "description": "Update channel - beta/preview releases" }, - "sectionSearchSource": "Search Source", + "sectionSearchSource": "Buscar Fuente", "@sectionSearchSource": { "description": "Settings section header" }, - "sectionDownload": "Download", + "sectionDownload": "Descargar", "@sectionDownload": { "description": "Settings section header" }, - "sectionPerformance": "Performance", + "sectionPerformance": "Alto rendimiento", "@sectionPerformance": { "description": "Settings section header" }, - "sectionApp": "App", + "sectionApp": "Aplicación", "@sectionApp": { "description": "Settings section header" }, - "sectionData": "Data", + "sectionData": "Datos", "@sectionData": { "description": "Settings section header" }, - "sectionDebug": "Debug", + "sectionDebug": "Depuración", "@sectionDebug": { "description": "Settings section header" }, - "sectionService": "Service", + "sectionService": "Servicio", "@sectionService": { "description": "Settings section header" }, - "sectionAudioQuality": "Audio Quality", + "sectionAudioQuality": "Calidad de Sonido", "@sectionAudioQuality": { "description": "Settings section header" }, - "sectionFileSettings": "File Settings", + "sectionFileSettings": "Ajustes del archivo", "@sectionFileSettings": { "description": "Settings section header" }, - "sectionColor": "Color", + "sectionColor": "Colores", "@sectionColor": { "description": "Settings section header" }, - "sectionTheme": "Theme", + "sectionTheme": "Tema", "@sectionTheme": { "description": "Settings section header" }, - "sectionLayout": "Layout", + "sectionLayout": "Diseño", "@sectionLayout": { "description": "Settings section header" }, - "sectionLanguage": "Language", + "sectionLanguage": "Idioma", "@sectionLanguage": { "description": "Settings section header for language" }, - "appearanceLanguage": "App Language", + "appearanceLanguage": "Idioma de la aplicación", "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", + "appearanceLanguageSubtitle": "Elija su idioma preferido", "@appearanceLanguageSubtitle": { "description": "Language setting subtitle" }, - "settingsAppearanceSubtitle": "Theme, colors, display", + "settingsAppearanceSubtitle": "Tema, colores, pantalla", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" }, - "settingsDownloadSubtitle": "Service, quality, filename format", + "settingsDownloadSubtitle": "Servicio, calidad, formato del nombre del archivo", "@settingsDownloadSubtitle": { "description": "Download settings description" }, - "settingsOptionsSubtitle": "Fallback, lyrics, cover art, updates", + "settingsOptionsSubtitle": "Alternativa, letras, carátula, actualizaciones", "@settingsOptionsSubtitle": { "description": "Options settings description" }, - "settingsExtensionsSubtitle": "Manage download providers", + "settingsExtensionsSubtitle": "Administrar proveedores de descarga", "@settingsExtensionsSubtitle": { "description": "Extensions settings description" }, - "settingsLogsSubtitle": "View app logs for debugging", + "settingsLogsSubtitle": "Ver registros de aplicaciones para depuración", "@settingsLogsSubtitle": { "description": "Logs settings description" }, - "loadingSharedLink": "Loading shared link...", + "loadingSharedLink": "Cargando enlace compartido...", "@loadingSharedLink": { "description": "Status when opening shared URL" }, - "pressBackAgainToExit": "Press back again to exit", + "pressBackAgainToExit": "Presione de nuevo para salir", "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", + "tracksHeader": "Pistas", "@tracksHeader": { "description": "Section header for track list" }, - "downloadAllCount": "Download All ({count})", + "downloadAllCount": "Descargar Todo ({count})", "@downloadAllCount": { "description": "Download all button with count", "placeholders": { @@ -1916,7 +1916,7 @@ } } }, - "tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -1925,111 +1925,111 @@ } } }, - "trackCopyFilePath": "Copy file path", + "trackCopyFilePath": "Copiar ruta de archivo", "@trackCopyFilePath": { "description": "Action - copy file path" }, - "trackRemoveFromDevice": "Remove from device", + "trackRemoveFromDevice": "Eliminar del dispositivo", "@trackRemoveFromDevice": { "description": "Action - delete downloaded file" }, - "trackLoadLyrics": "Load Lyrics", + "trackLoadLyrics": "Cargar letras", "@trackLoadLyrics": { "description": "Action - fetch lyrics" }, - "trackMetadata": "Metadata", + "trackMetadata": "Metadatos", "@trackMetadata": { "description": "Tab title - track metadata" }, - "trackFileInfo": "File Info", + "trackFileInfo": "Información de archivo", "@trackFileInfo": { "description": "Tab title - file information" }, - "trackLyrics": "Lyrics", + "trackLyrics": "Letras", "@trackLyrics": { "description": "Tab title - lyrics" }, - "trackFileNotFound": "File not found", + "trackFileNotFound": "Archivo no encontrado", "@trackFileNotFound": { "description": "Error - file doesn't exist" }, - "trackOpenInDeezer": "Open in Deezer", + "trackOpenInDeezer": "Abrir en Deezer", "@trackOpenInDeezer": { "description": "Action - open track in Deezer app" }, - "trackOpenInSpotify": "Open in Spotify", + "trackOpenInSpotify": "Abrir en Spotify", "@trackOpenInSpotify": { "description": "Action - open track in Spotify app" }, - "trackTrackName": "Track name", + "trackTrackName": "Nombre de pista", "@trackTrackName": { "description": "Metadata label - track title" }, - "trackArtist": "Artist", + "trackArtist": "Artista", "@trackArtist": { "description": "Metadata label - artist name" }, - "trackAlbumArtist": "Album artist", + "trackAlbumArtist": "Artista del álbum", "@trackAlbumArtist": { "description": "Metadata label - album artist" }, - "trackAlbum": "Album", + "trackAlbum": "Álbum", "@trackAlbum": { "description": "Metadata label - album name" }, - "trackTrackNumber": "Track number", + "trackTrackNumber": "Número de pista", "@trackTrackNumber": { "description": "Metadata label - track number" }, - "trackDiscNumber": "Disc number", + "trackDiscNumber": "Número de disco", "@trackDiscNumber": { "description": "Metadata label - disc number" }, - "trackDuration": "Duration", + "trackDuration": "Duración", "@trackDuration": { "description": "Metadata label - track length" }, - "trackAudioQuality": "Audio quality", + "trackAudioQuality": "Calidad del sonido", "@trackAudioQuality": { "description": "Metadata label - audio quality" }, - "trackReleaseDate": "Release date", + "trackReleaseDate": "Fecha de lanzamiento", "@trackReleaseDate": { "description": "Metadata label - release date" }, - "trackDownloaded": "Downloaded", + "trackDownloaded": "Descargado", "@trackDownloaded": { "description": "Metadata label - download date" }, - "trackCopyLyrics": "Copy lyrics", + "trackCopyLyrics": "Copiar letras", "@trackCopyLyrics": { "description": "Action - copy lyrics to clipboard" }, - "trackLyricsNotAvailable": "Lyrics not available for this track", + "trackLyricsNotAvailable": "Letras no disponibles para este tema", "@trackLyricsNotAvailable": { "description": "Message when lyrics not found" }, - "trackLyricsTimeout": "Request timed out. Try again later.", + "trackLyricsTimeout": "Tiempo de espera agotado. Inténtalo de nuevo más tarde.", "@trackLyricsTimeout": { "description": "Message when lyrics request times out" }, - "trackLyricsLoadFailed": "Failed to load lyrics", + "trackLyricsLoadFailed": "Error al cargar la letra", "@trackLyricsLoadFailed": { "description": "Message when lyrics loading fails" }, - "trackCopiedToClipboard": "Copied to clipboard", + "trackCopiedToClipboard": "Copiado al portapapeles", "@trackCopiedToClipboard": { "description": "Snackbar - content copied" }, - "trackDeleteConfirmTitle": "Remove from device?", + "trackDeleteConfirmTitle": "¿Eliminar del dispositivo?", "@trackDeleteConfirmTitle": { "description": "Delete confirmation title" }, - "trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", + "trackDeleteConfirmMessage": "Esto eliminará permanentemente el archivo descargado y lo eliminará de tu historial.", "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", + "trackCannotOpen": "No se puede abrir: {message}", "@trackCannotOpen": { "description": "Error opening file", "placeholders": { @@ -2038,15 +2038,15 @@ } } }, - "dateToday": "Today", + "dateToday": "Hoy", "@dateToday": { "description": "Relative date - today" }, - "dateYesterday": "Yesterday", + "dateYesterday": "Ayer", "@dateYesterday": { "description": "Relative date - yesterday" }, - "dateDaysAgo": "{count} days ago", + "dateDaysAgo": "Hace {count} días", "@dateDaysAgo": { "description": "Relative date - days ago", "placeholders": { @@ -2055,7 +2055,7 @@ } } }, - "dateWeeksAgo": "{count} weeks ago", + "dateWeeksAgo": "{count} semanas antes", "@dateWeeksAgo": { "description": "Relative date - weeks ago", "placeholders": { @@ -2064,7 +2064,7 @@ } } }, - "dateMonthsAgo": "{count} months ago", + "dateMonthsAgo": "{count} meses atrás", "@dateMonthsAgo": { "description": "Relative date - months ago", "placeholders": { @@ -2073,71 +2073,71 @@ } } }, - "concurrentSequential": "Sequential", + "concurrentSequential": "Secuencial", "@concurrentSequential": { "description": "Download mode - one at a time" }, - "concurrentParallel2": "2 Parallel", + "concurrentParallel2": "2 simultáneamente", "@concurrentParallel2": { "description": "Download mode - 2 simultaneous" }, - "concurrentParallel3": "3 Parallel", + "concurrentParallel3": "3 simultáneamente", "@concurrentParallel3": { "description": "Download mode - 3 simultaneous" }, - "tapToSeeError": "Tap to see error details", + "tapToSeeError": "Pulse para ver los detalles del error", "@tapToSeeError": { "description": "Tooltip for failed download" }, - "storeFilterAll": "All", + "storeFilterAll": "Todo", "@storeFilterAll": { "description": "Store filter - all extensions" }, - "storeFilterMetadata": "Metadata", + "storeFilterMetadata": "Metadatos", "@storeFilterMetadata": { "description": "Store filter - metadata providers" }, - "storeFilterDownload": "Download", + "storeFilterDownload": "Descargar", "@storeFilterDownload": { "description": "Store filter - download providers" }, - "storeFilterUtility": "Utility", + "storeFilterUtility": "Utilidad", "@storeFilterUtility": { "description": "Store filter - utility extensions" }, - "storeFilterLyrics": "Lyrics", + "storeFilterLyrics": "Letras", "@storeFilterLyrics": { "description": "Store filter - lyrics providers" }, - "storeFilterIntegration": "Integration", + "storeFilterIntegration": "Integración", "@storeFilterIntegration": { "description": "Store filter - integrations" }, - "storeClearFilters": "Clear filters", + "storeClearFilters": "Limpiar filtros", "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", + "storeNoResults": "No se encontraron extensiones", "@storeNoResults": { "description": "Empty state when no extensions match filters" }, - "extensionProviderPriority": "Provider Priority", + "extensionProviderPriority": "Prioridad del proveedor", "@extensionProviderPriority": { "description": "Extension capability - provider priority" }, - "extensionInstallButton": "Install Extension", + "extensionInstallButton": "Instalar extensión", "@extensionInstallButton": { "description": "Button to install extension" }, - "extensionDefaultProvider": "Default (Deezer/Spotify)", + "extensionDefaultProvider": "Por defecto (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" }, - "extensionDefaultProviderSubtitle": "Use built-in search", + "extensionDefaultProviderSubtitle": "Usar búsqueda integrada", "@extensionDefaultProviderSubtitle": { "description": "Subtitle for default provider" }, - "extensionAuthor": "Author", + "extensionAuthor": "Autor/a", "@extensionAuthor": { "description": "Extension detail - author" }, @@ -2149,63 +2149,63 @@ "@extensionError": { "description": "Extension detail - error message" }, - "extensionCapabilities": "Capabilities", + "extensionCapabilities": "Recursos", "@extensionCapabilities": { "description": "Section header - extension features" }, - "extensionMetadataProvider": "Metadata Provider", + "extensionMetadataProvider": "Proveedor de metadatos", "@extensionMetadataProvider": { "description": "Capability - provides metadata" }, - "extensionDownloadProvider": "Download Provider", + "extensionDownloadProvider": "Proveedor de descargas", "@extensionDownloadProvider": { "description": "Capability - provides downloads" }, - "extensionLyricsProvider": "Lyrics Provider", + "extensionLyricsProvider": "Proveedor de letras", "@extensionLyricsProvider": { "description": "Capability - provides lyrics" }, - "extensionUrlHandler": "URL Handler", + "extensionUrlHandler": "Gestor de URL", "@extensionUrlHandler": { "description": "Capability - handles URLs" }, - "extensionQualityOptions": "Quality Options", + "extensionQualityOptions": "Opciones de calidad", "@extensionQualityOptions": { "description": "Capability - quality selection" }, - "extensionPostProcessingHooks": "Post-Processing Hooks", + "extensionPostProcessingHooks": "Hooks post-procesamiento", "@extensionPostProcessingHooks": { "description": "Capability - post-processing" }, - "extensionPermissions": "Permissions", + "extensionPermissions": "Permisos", "@extensionPermissions": { "description": "Section header - required permissions" }, - "extensionSettings": "Settings", + "extensionSettings": "Ajustes", "@extensionSettings": { "description": "Section header - extension settings" }, - "extensionRemoveButton": "Remove Extension", + "extensionRemoveButton": "Eliminar extensión", "@extensionRemoveButton": { "description": "Button to uninstall extension" }, - "extensionUpdated": "Updated", + "extensionUpdated": "Actualizado", "@extensionUpdated": { "description": "Extension detail - last update" }, - "extensionMinAppVersion": "Min App Version", + "extensionMinAppVersion": "Versión Mínima de la aplicación", "@extensionMinAppVersion": { "description": "Extension detail - minimum app version" }, - "extensionCustomTrackMatching": "Custom Track Matching", + "extensionCustomTrackMatching": "Coincidencia de pista personalizada", "@extensionCustomTrackMatching": { "description": "Capability - custom track matching algorithm" }, - "extensionPostProcessing": "Post-Processing", + "extensionPostProcessing": "Post-Procesamiento", "@extensionPostProcessing": { "description": "Capability - post-download processing" }, - "extensionHooksAvailable": "{count} hook(s) available", + "extensionHooksAvailable": "{count} hook(s) disponibles", "@extensionHooksAvailable": { "description": "Post-processing hooks count", "placeholders": { @@ -2214,7 +2214,7 @@ } } }, - "extensionPatternsCount": "{count} pattern(s)", + "extensionPatternsCount": "Patrón(es) {count}", "@extensionPatternsCount": { "description": "URL patterns count", "placeholders": { @@ -2223,7 +2223,7 @@ } } }, - "extensionStrategy": "Strategy: {strategy}", + "extensionStrategy": "Estrategia: {strategy}", "@extensionStrategy": { "description": "Track matching strategy name", "placeholders": { @@ -2232,75 +2232,75 @@ } } }, - "extensionsProviderPrioritySection": "Provider Priority", + "extensionsProviderPrioritySection": "Prioridad del proveedor", "@extensionsProviderPrioritySection": { "description": "Section header - provider priority" }, - "extensionsInstalledSection": "Installed Extensions", + "extensionsInstalledSection": "Extensiones instaladas", "@extensionsInstalledSection": { "description": "Section header - installed extensions" }, - "extensionsNoExtensions": "No extensions installed", + "extensionsNoExtensions": "No hay extensiones instaladas", "@extensionsNoExtensions": { "description": "Empty state - no extensions" }, - "extensionsNoExtensionsSubtitle": "Install .spotiflac-ext files to add new providers", + "extensionsNoExtensionsSubtitle": "Instalar archivos .spotiflac-ext para añadir nuevos proveedores", "@extensionsNoExtensionsSubtitle": { "description": "Empty state subtitle" }, - "extensionsInstallButton": "Install Extension", + "extensionsInstallButton": "Instalar extensión", "@extensionsInstallButton": { "description": "Button to install extension from file" }, - "extensionsInfoTip": "Extensions can add new metadata and download providers. Only install extensions from trusted sources.", + "extensionsInfoTip": "Las extensiones pueden añadir nuevos metadatos y proveedores de descargas. Sólo instalar extensiones desde fuentes confiables.", "@extensionsInfoTip": { "description": "Security warning about extensions" }, - "extensionsInstalledSuccess": "Extension installed successfully", + "extensionsInstalledSuccess": "Extensión instalada correctamente", "@extensionsInstalledSuccess": { "description": "Success message after install" }, - "extensionsDownloadPriority": "Download Priority", + "extensionsDownloadPriority": "Prioridad de descarga", "@extensionsDownloadPriority": { "description": "Setting - download provider order" }, - "extensionsDownloadPrioritySubtitle": "Set download service order", + "extensionsDownloadPrioritySubtitle": "Establecer orden de servicio de descarga", "@extensionsDownloadPrioritySubtitle": { "description": "Subtitle for download priority" }, - "extensionsNoDownloadProvider": "No extensions with download provider", + "extensionsNoDownloadProvider": "No hay extensiones con proveedor de descargas", "@extensionsNoDownloadProvider": { "description": "Empty state - no download providers" }, - "extensionsMetadataPriority": "Metadata Priority", + "extensionsMetadataPriority": "Prioridad de los metadatos", "@extensionsMetadataPriority": { "description": "Setting - metadata provider order" }, - "extensionsMetadataPrioritySubtitle": "Set search & metadata source order", + "extensionsMetadataPrioritySubtitle": "Establecer orden de búsqueda y metadatos", "@extensionsMetadataPrioritySubtitle": { "description": "Subtitle for metadata priority" }, - "extensionsNoMetadataProvider": "No extensions with metadata provider", + "extensionsNoMetadataProvider": "No hay extensiones con el proveedor de metadatos", "@extensionsNoMetadataProvider": { "description": "Empty state - no metadata providers" }, - "extensionsSearchProvider": "Search Provider", + "extensionsSearchProvider": "Proveedor de búsqueda", "@extensionsSearchProvider": { "description": "Setting - search provider selection" }, - "extensionsNoCustomSearch": "No extensions with custom search", + "extensionsNoCustomSearch": "No hay extensiones con búsqueda personalizada", "@extensionsNoCustomSearch": { "description": "Empty state - no search providers" }, - "extensionsSearchProviderDescription": "Choose which service to use for searching tracks", + "extensionsSearchProviderDescription": "Elegir qué servicio usar para buscar pistas", "@extensionsSearchProviderDescription": { "description": "Search provider setting description" }, - "extensionsCustomSearch": "Custom search", + "extensionsCustomSearch": "Búsqueda personalizada", "@extensionsCustomSearch": { "description": "Label for custom search provider" }, - "extensionsErrorLoading": "Error loading extension", + "extensionsErrorLoading": "Error al cargar la extensión", "@extensionsErrorLoading": { "description": "Error message when extension fails to load" }, @@ -2316,7 +2316,7 @@ "@qualityHiResFlac": { "description": "Quality option - high resolution FLAC" }, - "qualityHiResFlacSubtitle": "24-bit / up to 96kHz", + "qualityHiResFlacSubtitle": "24 bits/hasta 96kHz", "@qualityHiResFlacSubtitle": { "description": "Technical spec for hi-res" }, @@ -2324,83 +2324,83 @@ "@qualityHiResFlacMax": { "description": "Quality option - maximum resolution FLAC" }, - "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", + "qualityHiResFlacMaxSubtitle": "24 bits / hasta 192kHz", "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityNote": "Actual quality depends on track availability from the service", + "qualityNote": "La calidad real depende de la disponibilidad de la pista del servicio", "@qualityNote": { "description": "Note about quality availability" }, - "downloadAskBeforeDownload": "Ask Before Download", + "downloadAskBeforeDownload": "Preguntar antes de descargar", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" }, - "downloadDirectory": "Download Directory", + "downloadDirectory": "Carpeta de descarga", "@downloadDirectory": { "description": "Setting - download folder" }, - "downloadSeparateSinglesFolder": "Separate Singles Folder", + "downloadSeparateSinglesFolder": "Carpeta separada para pistas", "@downloadSeparateSinglesFolder": { "description": "Setting - separate folder for singles" }, - "downloadAlbumFolderStructure": "Album Folder Structure", + "downloadAlbumFolderStructure": "Estructura de carpeta del álbum", "@downloadAlbumFolderStructure": { "description": "Setting - album folder organization" }, - "downloadSaveFormat": "Save Format", + "downloadSaveFormat": "Guardar Formato", "@downloadSaveFormat": { "description": "Setting - output file format" }, - "downloadSelectService": "Select Service", + "downloadSelectService": "Seleccionar Servicio", "@downloadSelectService": { "description": "Dialog title - choose download service" }, - "downloadSelectQuality": "Select Quality", + "downloadSelectQuality": "Seleccionar Calidad", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" }, - "downloadFrom": "Download From", + "downloadFrom": "Descargar Desde", "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", + "downloadDefaultQualityLabel": "Calidad por Defecto", "@downloadDefaultQualityLabel": { "description": "Label - default quality setting" }, - "downloadBestAvailable": "Best available", + "downloadBestAvailable": "La mejor disponible", "@downloadBestAvailable": { "description": "Quality option - highest available" }, - "folderNone": "None", + "folderNone": "Ninguna", "@folderNone": { "description": "Folder option - no organization" }, - "folderNoneSubtitle": "Save all files directly to download folder", + "folderNoneSubtitle": "Guardar todos los archivos directamente para descargar la carpeta", "@folderNoneSubtitle": { "description": "Subtitle for no folder organization" }, - "folderArtist": "Artist", + "folderArtist": "Artista", "@folderArtist": { "description": "Folder option - by artist" }, - "folderArtistSubtitle": "Artist Name/filename", + "folderArtistSubtitle": "Nombre del Artista/nombre de archivo", "@folderArtistSubtitle": { "description": "Folder structure example" }, - "folderAlbum": "Album", + "folderAlbum": "Álbum", "@folderAlbum": { "description": "Folder option - by album" }, - "folderAlbumSubtitle": "Album Name/filename", + "folderAlbumSubtitle": "Nombre del álbum/nombre de archivo", "@folderAlbumSubtitle": { "description": "Folder structure example" }, - "folderArtistAlbum": "Artist/Album", + "folderArtistAlbum": "Artista/Álbum", "@folderArtistAlbum": { "description": "Folder option - nested" }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", + "folderArtistAlbumSubtitle": "Nombre del Artista/Nombre del Álbum/Nombre del Archivo", "@folderArtistAlbumSubtitle": { "description": "Folder structure example" }, @@ -2424,55 +2424,55 @@ "@serviceSpotify": { "description": "Service name - DO NOT TRANSLATE" }, - "appearanceAmoledDark": "AMOLED Dark", + "appearanceAmoledDark": "AMOLED Oscuro", "@appearanceAmoledDark": { "description": "Theme option - pure black" }, - "appearanceAmoledDarkSubtitle": "Pure black background", + "appearanceAmoledDarkSubtitle": "Fondo negro puro", "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", + "appearanceChooseAccentColor": "Elegir color principal", "@appearanceChooseAccentColor": { "description": "Color picker dialog title" }, - "appearanceChooseTheme": "Theme Mode", + "appearanceChooseTheme": "Modo de tema", "@appearanceChooseTheme": { "description": "Theme picker dialog title" }, - "queueTitle": "Download Queue", + "queueTitle": "Descargas en proceso", "@queueTitle": { "description": "Queue screen title" }, - "queueClearAll": "Clear All", + "queueClearAll": "Eliminar todo", "@queueClearAll": { "description": "Button - clear all queue items" }, - "queueClearAllMessage": "Are you sure you want to clear all downloads?", + "queueClearAllMessage": "¿Estás seguro de que quieres borrar todas las descargas?", "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueEmpty": "No downloads in queue", + "queueEmpty": "No hay descargas en cola", "@queueEmpty": { "description": "Empty queue state title" }, - "queueEmptySubtitle": "Add tracks from the home screen", + "queueEmptySubtitle": "Añadir pistas desde la pantalla de inicio", "@queueEmptySubtitle": { "description": "Empty queue state subtitle" }, - "queueClearCompleted": "Clear completed", + "queueClearCompleted": "Limpiar tareas finalizadas", "@queueClearCompleted": { "description": "Button - clear finished downloads" }, - "queueDownloadFailed": "Download Failed", + "queueDownloadFailed": "Descarga fallida", "@queueDownloadFailed": { "description": "Error dialog title" }, - "queueTrackLabel": "Track:", + "queueTrackLabel": "Pista:", "@queueTrackLabel": { "description": "Label in error dialog" }, - "queueArtistLabel": "Artist:", + "queueArtistLabel": "Artista:", "@queueArtistLabel": { "description": "Label in error dialog" }, @@ -2480,47 +2480,47 @@ "@queueErrorLabel": { "description": "Label in error dialog" }, - "queueUnknownError": "Unknown error", + "queueUnknownError": "Error desconocido", "@queueUnknownError": { "description": "Fallback error message" }, - "albumFolderArtistAlbum": "Artist / Album", + "albumFolderArtistAlbum": "Artista / Álbum", "@albumFolderArtistAlbum": { "description": "Album folder option" }, - "albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/", + "albumFolderArtistAlbumSubtitle": "Álbumes/Nombre del Artista/Nombre del Álbum/", "@albumFolderArtistAlbumSubtitle": { "description": "Folder structure example" }, - "albumFolderArtistYearAlbum": "Artist / [Year] Album", + "albumFolderArtistYearAlbum": "Artista / [Año] Álbum", "@albumFolderArtistYearAlbum": { "description": "Album folder option with year" }, - "albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/", + "albumFolderArtistYearAlbumSubtitle": "Álbumes/Nombre del Artista /[2005] Nombre del Álbum/", "@albumFolderArtistYearAlbumSubtitle": { "description": "Folder structure example" }, - "albumFolderAlbumOnly": "Album Only", + "albumFolderAlbumOnly": "Sólo álbum", "@albumFolderAlbumOnly": { "description": "Album folder option" }, - "albumFolderAlbumOnlySubtitle": "Albums/Album Name/", + "albumFolderAlbumOnlySubtitle": "Álbumes/Nombre del Álbum/", "@albumFolderAlbumOnlySubtitle": { "description": "Folder structure example" }, - "albumFolderYearAlbum": "[Year] Album", + "albumFolderYearAlbum": "Álbum [Año]", "@albumFolderYearAlbum": { "description": "Album folder option with year" }, - "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", + "albumFolderYearAlbumSubtitle": "Álbumes/[2005] Nombre del Álbum/", "@albumFolderYearAlbumSubtitle": { "description": "Folder structure example" }, - "downloadedAlbumDeleteSelected": "Delete Selected", + "downloadedAlbumDeleteSelected": "Borrar Seleccionados", "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.", + "downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -2529,11 +2529,11 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", + "downloadedAlbumTracksHeader": "Pistas", "@downloadedAlbumTracksHeader": { "description": "Section header for tracks" }, - "downloadedAlbumDownloadedCount": "{count} downloaded", + "downloadedAlbumDownloadedCount": "{count} descargado", "@downloadedAlbumDownloadedCount": { "description": "Downloaded tracks count badge", "placeholders": { @@ -2542,7 +2542,7 @@ } } }, - "downloadedAlbumSelectedCount": "{count} selected", + "downloadedAlbumSelectedCount": "{count} seleccionado", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", "placeholders": { @@ -2551,15 +2551,15 @@ } } }, - "downloadedAlbumAllSelected": "All tracks selected", + "downloadedAlbumAllSelected": "Todas las pistas seleccionadas", "@downloadedAlbumAllSelected": { "description": "Status - all items selected" }, - "downloadedAlbumTapToSelect": "Tap tracks to select", + "downloadedAlbumTapToSelect": "Toca las pistas para seleccionar", "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}", + "downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { @@ -2568,31 +2568,31 @@ } } }, - "downloadedAlbumSelectToDelete": "Select tracks to delete", + "downloadedAlbumSelectToDelete": "Seleccionar pistas a eliminar", "@downloadedAlbumSelectToDelete": { "description": "Placeholder when nothing selected" }, - "utilityFunctions": "Utility Functions", + "utilityFunctions": "Funciones de utilidad", "@utilityFunctions": { "description": "Extension capability - utility functions" }, - "recentTypeArtist": "Artist", + "recentTypeArtist": "Artista", "@recentTypeArtist": { "description": "Recent access item type - artist" }, - "recentTypeAlbum": "Album", + "recentTypeAlbum": "Álbum", "@recentTypeAlbum": { "description": "Recent access item type - album" }, - "recentTypeSong": "Song", + "recentTypeSong": "Canción", "@recentTypeSong": { "description": "Recent access item type - song/track" }, - "recentTypePlaylist": "Playlist", + "recentTypePlaylist": "Lista de reproducción", "@recentTypePlaylist": { "description": "Recent access item type - playlist" }, - "recentPlaylistInfo": "Playlist: {name}", + "recentPlaylistInfo": "Lista de reproducción: {name}", "@recentPlaylistInfo": { "description": "Snackbar message when tapping playlist in recent access", "placeholders": { From ff9d088c5fc67a80a02ce735f940cceab3bf44ac Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:29:34 +0700 Subject: [PATCH 13/48] New translations app_en.arb (German) --- lib/l10n/arb/app_de.arb | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8a57812d..28890472 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -131,7 +131,7 @@ "@settingsTitle": { "description": "Settings screen title" }, - "settingsDownload": "Download", + "settingsDownload": "Herunterladen", "@settingsDownload": { "description": "Settings section - download options" }, @@ -151,7 +151,7 @@ "@settingsAbout": { "description": "Settings section - app info" }, - "downloadTitle": "Download", + "downloadTitle": "Herunterladen", "@downloadTitle": { "description": "Download settings page title" }, @@ -508,11 +508,11 @@ "@aboutOriginalCreator": { "description": "Role description for original creator" }, - "aboutLogoArtist": "The talented artist who created our beautiful app logo!", + "aboutLogoArtist": "Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!", "@aboutLogoArtist": { "description": "Role description for logo artist" }, - "aboutSpecialThanks": "Special Thanks", + "aboutSpecialThanks": "Besonderer Dank", "@aboutSpecialThanks": { "description": "Section for special thanks" }, @@ -520,27 +520,27 @@ "@aboutLinks": { "description": "Section for external links" }, - "aboutMobileSource": "Mobile source code", + "aboutMobileSource": "Mobiler Quellcode", "@aboutMobileSource": { "description": "Link to mobile GitHub repo" }, - "aboutPCSource": "PC source code", + "aboutPCSource": "PC Quellcode", "@aboutPCSource": { "description": "Link to PC GitHub repo" }, - "aboutReportIssue": "Report an issue", + "aboutReportIssue": "Problem melden", "@aboutReportIssue": { "description": "Link to report bugs" }, - "aboutReportIssueSubtitle": "Report any problems you encounter", + "aboutReportIssueSubtitle": "Melde jedes Problem, die dir auftreten", "@aboutReportIssueSubtitle": { "description": "Subtitle for report issue" }, - "aboutFeatureRequest": "Feature request", + "aboutFeatureRequest": "Feature vorschlagen", "@aboutFeatureRequest": { "description": "Link to suggest features" }, - "aboutFeatureRequestSubtitle": "Suggest new features for the app", + "aboutFeatureRequestSubtitle": "Schlage neue Funktionen für die App vor", "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, @@ -548,11 +548,11 @@ "@aboutSupport": { "description": "Section for support/donation links" }, - "aboutBuyMeCoffee": "Buy me a coffee", + "aboutBuyMeCoffee": "Spendiere mir einen Kaffee", "@aboutBuyMeCoffee": { "description": "Donation link" }, - "aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi", + "aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi", "@aboutBuyMeCoffeeSubtitle": { "description": "Subtitle for donation" }, @@ -564,11 +564,11 @@ "@aboutVersion": { "description": "Version info label" }, - "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", + "aboutBinimumDesc": "Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!", "@aboutBinimumDesc": { "description": "Credit description for binimum" }, - "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", + "aboutSachinsenalDesc": "Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!", "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, @@ -576,7 +576,7 @@ "@aboutDoubleDouble": { "description": "Name of Amazon API service - DO NOT TRANSLATE" }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", + "aboutDoubleDoubleDesc": "Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!", "@aboutDoubleDoubleDesc": { "description": "Credit for DoubleDouble API" }, @@ -584,7 +584,7 @@ "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" }, - "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", + "aboutDabMusicDesc": "Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!", "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, From 3747674968409de902d16cdfb104a25378d9badf Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:29:37 +0700 Subject: [PATCH 14/48] New translations app_en.arb (Russian) --- lib/l10n/arb/app_ru.arb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index bff3e1bf..e7dfeecb 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -1945,7 +1945,7 @@ "@trackFileInfo": { "description": "Tab title - file information" }, - "trackLyrics": "Тексты песен", + "trackLyrics": "Текст песни", "@trackLyrics": { "description": "Tab title - lyrics" }, @@ -1961,7 +1961,7 @@ "@trackOpenInSpotify": { "description": "Action - open track in Spotify app" }, - "trackTrackName": "Название трека", + "trackTrackName": "Название", "@trackTrackName": { "description": "Metadata label - track title" }, From be3ee3b2169b1427583b9c26c84cb985a19ba16d Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:29:39 +0700 Subject: [PATCH 15/48] New translations app_en.arb (Chinese Traditional) --- lib/l10n/arb/app_zh_TW.arb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 8526e88f..3798a905 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -51,7 +51,7 @@ "@homeSupports": { "description": "Info text about supported URL types" }, - "homeRecent": "Recent", + "homeRecent": "最新的", "@homeRecent": { "description": "Section header for recent searches" }, From 5d03eb06564090ef291562cbe1fd2f4ad35253f3 Mon Sep 17 00:00:00 2001 From: Zarz Eleutherius <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Jan 2026 02:11:51 +0700 Subject: [PATCH 16/48] New translations app_en.arb (Portuguese) --- lib/l10n/arb/app_pt-PT.arb | 822 ++++++++++++++++++------------------- 1 file changed, 411 insertions(+), 411 deletions(-) diff --git a/lib/l10n/arb/app_pt-PT.arb b/lib/l10n/arb/app_pt-PT.arb index 7f007ecc..b2703f94 100644 --- a/lib/l10n/arb/app_pt-PT.arb +++ b/lib/l10n/arb/app_pt-PT.arb @@ -5,35 +5,35 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "appDescription": "Baixe faixas do Spotify em qualidade sem perdas de Tidal, Qobuz e Amazon Music.", "@appDescription": { "description": "App description shown in about page" }, - "navHome": "Home", + "navHome": "Início", "@navHome": { "description": "Bottom navigation - Home tab" }, - "navHistory": "History", + "navHistory": "Histórico", "@navHistory": { "description": "Bottom navigation - History tab" }, - "navSettings": "Settings", + "navSettings": "Configurações", "@navSettings": { "description": "Bottom navigation - Settings tab" }, - "navStore": "Store", + "navStore": "Loja", "@navStore": { "description": "Bottom navigation - Extension store tab" }, - "homeTitle": "Home", + "homeTitle": "Início", "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", + "homeSearchHint": "Pesquise ou cole a URL do Spotify...", "@homeSearchHint": { "description": "Placeholder text in search box" }, - "homeSearchHintExtension": "Search with {extensionName}...", + "homeSearchHintExtension": "Pesquisar com {extensionName}...", "@homeSearchHintExtension": { "description": "Placeholder when extension search is active", "placeholders": { @@ -43,23 +43,23 @@ } } }, - "homeSubtitle": "Paste a Spotify link or search by name", + "homeSubtitle": "Cole um link do Spotify ou procure por nome", "@homeSubtitle": { "description": "Subtitle shown below search box" }, - "homeSupports": "Supports: Track, Album, Playlist, Artist URLs", + "homeSupports": "Suporte: Faixas, Álbuns, Playlists, URLs de Artista", "@homeSupports": { "description": "Info text about supported URL types" }, - "homeRecent": "Recent", + "homeRecent": "Recentes", "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", + "historyTitle": "Histórico", "@historyTitle": { "description": "History screen title" }, - "historyDownloading": "Downloading ({count})", + "historyDownloading": "Baixando ({count})", "@historyDownloading": { "description": "Tab showing active downloads count", "placeholders": { @@ -69,15 +69,15 @@ } } }, - "historyDownloaded": "Downloaded", + "historyDownloaded": "Baixados", "@historyDownloaded": { "description": "Tab showing completed downloads" }, - "historyFilterAll": "All", + "historyFilterAll": "Tudo", "@historyFilterAll": { "description": "Filter chip - show all items" }, - "historyFilterAlbums": "Albums", + "historyFilterAlbums": "Álbuns", "@historyFilterAlbums": { "description": "Filter chip - show albums only" }, @@ -85,7 +85,7 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", "@historyTracksCount": { "description": "Track count with plural form", "placeholders": { @@ -94,7 +94,7 @@ } } }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", + "historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}", "@historyAlbumsCount": { "description": "Album count with plural form", "placeholders": { @@ -103,31 +103,31 @@ } } }, - "historyNoDownloads": "No download history", + "historyNoDownloads": "Nenhum histórico de downloads", "@historyNoDownloads": { "description": "Empty state title" }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", + "historyNoDownloadsSubtitle": "As faixas baixadas aparecerão aqui", "@historyNoDownloadsSubtitle": { "description": "Empty state subtitle" }, - "historyNoAlbums": "No album downloads", + "historyNoAlbums": "Sem álbuns baixados", "@historyNoAlbums": { "description": "Empty state when filtering albums" }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", + "historyNoAlbumsSubtitle": "Baixe várias faixas de um álbum para vê-las aqui", "@historyNoAlbumsSubtitle": { "description": "Empty state subtitle for albums filter" }, - "historyNoSingles": "No single downloads", + "historyNoSingles": "Sem singles baixados", "@historyNoSingles": { "description": "Empty state when filtering singles" }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", + "historyNoSinglesSubtitle": "Os downloads de faixa individuais aparecerão aqui", "@historyNoSinglesSubtitle": { "description": "Empty state subtitle for singles filter" }, - "settingsTitle": "Settings", + "settingsTitle": "Configurações", "@settingsTitle": { "description": "Settings screen title" }, @@ -135,19 +135,19 @@ "@settingsDownload": { "description": "Settings section - download options" }, - "settingsAppearance": "Appearance", + "settingsAppearance": "Aparência", "@settingsAppearance": { "description": "Settings section - visual customization" }, - "settingsOptions": "Options", + "settingsOptions": "Opções", "@settingsOptions": { "description": "Settings section - app options" }, - "settingsExtensions": "Extensions", + "settingsExtensions": "Extensões", "@settingsExtensions": { "description": "Settings section - extension management" }, - "settingsAbout": "About", + "settingsAbout": "Sobre", "@settingsAbout": { "description": "Settings section - app info" }, @@ -155,55 +155,55 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", + "downloadLocation": "Local dos Downloads", "@downloadLocation": { "description": "Setting for download folder" }, - "downloadLocationSubtitle": "Choose where to save files", + "downloadLocationSubtitle": "Escolha onde salvar os arquivos", "@downloadLocationSubtitle": { "description": "Subtitle for download location" }, - "downloadLocationDefault": "Default location", + "downloadLocationDefault": "Local padrão", "@downloadLocationDefault": { "description": "Shown when using default folder" }, - "downloadDefaultService": "Default Service", + "downloadDefaultService": "Serviço Padrão", "@downloadDefaultService": { "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" }, - "downloadDefaultServiceSubtitle": "Service used for downloads", + "downloadDefaultServiceSubtitle": "Serviço usado para downloads", "@downloadDefaultServiceSubtitle": { "description": "Subtitle for default service" }, - "downloadDefaultQuality": "Default Quality", + "downloadDefaultQuality": "Qualidade Predefinida", "@downloadDefaultQuality": { "description": "Setting for audio quality" }, - "downloadAskQuality": "Ask Quality Before Download", + "downloadAskQuality": "Perguntar qualidade antes de baixar", "@downloadAskQuality": { "description": "Toggle to show quality picker" }, - "downloadAskQualitySubtitle": "Show quality picker for each download", + "downloadAskQualitySubtitle": "Mostrar seletor de qualidade para cada download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" }, - "downloadFilenameFormat": "Filename Format", + "downloadFilenameFormat": "Formato do Nome do Arquivo", "@downloadFilenameFormat": { "description": "Setting for output filename pattern" }, - "downloadFolderOrganization": "Folder Organization", + "downloadFolderOrganization": "Organização de Pastas", "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", + "downloadSeparateSingles": "Separar Singles", "@downloadSeparateSingles": { "description": "Toggle to separate single tracks" }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", + "downloadSeparateSinglesSubtitle": "Colocar singles numa pasta separada", "@downloadSeparateSinglesSubtitle": { "description": "Subtitle for separate singles toggle" }, - "qualityBest": "Best Available", + "qualityBest": "Melhor Disponível", "@qualityBest": { "description": "Audio quality option - highest available" }, @@ -219,67 +219,67 @@ "@quality128": { "description": "Audio quality option - 128kbps MP3" }, - "appearanceTitle": "Appearance", + "appearanceTitle": "Aparência", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", + "appearanceTheme": "Tema", "@appearanceTheme": { "description": "Theme mode setting" }, - "appearanceThemeSystem": "System", + "appearanceThemeSystem": "Sistema", "@appearanceThemeSystem": { "description": "Follow system theme" }, - "appearanceThemeLight": "Light", + "appearanceThemeLight": "Claro", "@appearanceThemeLight": { "description": "Light theme" }, - "appearanceThemeDark": "Dark", + "appearanceThemeDark": "Escuro", "@appearanceThemeDark": { "description": "Dark theme" }, - "appearanceDynamicColor": "Dynamic Color", + "appearanceDynamicColor": "Cores Dinâmicas", "@appearanceDynamicColor": { "description": "Material You dynamic colors" }, - "appearanceDynamicColorSubtitle": "Use colors from your wallpaper", + "appearanceDynamicColorSubtitle": "Usar cores do seu papel de parede", "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", + "appearanceAccentColor": "Cor de Destaque", "@appearanceAccentColor": { "description": "Custom accent color picker" }, - "appearanceHistoryView": "History View", + "appearanceHistoryView": "Visualização do Histórico", "@appearanceHistoryView": { "description": "Layout style for history" }, - "appearanceHistoryViewList": "List", + "appearanceHistoryViewList": "Lista", "@appearanceHistoryViewList": { "description": "List layout option" }, - "appearanceHistoryViewGrid": "Grid", + "appearanceHistoryViewGrid": "Grade", "@appearanceHistoryViewGrid": { "description": "Grid layout option" }, - "optionsTitle": "Options", + "optionsTitle": "Opções", "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", + "optionsSearchSource": "Origem da Pesquisa", "@optionsSearchSource": { "description": "Section for search provider settings" }, - "optionsPrimaryProvider": "Primary Provider", + "optionsPrimaryProvider": "Provedor Primário", "@optionsPrimaryProvider": { "description": "Main search provider setting" }, - "optionsPrimaryProviderSubtitle": "Service used when searching by track name.", + "optionsPrimaryProviderSubtitle": "Serviço usado ao pesquisar por nome da faixa.", "@optionsPrimaryProviderSubtitle": { "description": "Subtitle for primary provider" }, - "optionsUsingExtension": "Using extension: {extensionName}", + "optionsUsingExtension": "Usando a extensão: {extensionName}", "@optionsUsingExtension": { "description": "Shows active extension name", "placeholders": { @@ -288,55 +288,55 @@ } } }, - "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", + "optionsSwitchBack": "Toque no Deezer ou Spotify para alternar de volta da extensão", "@optionsSwitchBack": { "description": "Hint to switch back to built-in providers" }, - "optionsAutoFallback": "Auto Fallback", + "optionsAutoFallback": "Fallback Automático", "@optionsAutoFallback": { "description": "Auto-retry with other services" }, - "optionsAutoFallbackSubtitle": "Try other services if download fails", + "optionsAutoFallbackSubtitle": "Tentar outros serviços se o download falhar", "@optionsAutoFallbackSubtitle": { "description": "Subtitle for auto fallback" }, - "optionsUseExtensionProviders": "Use Extension Providers", + "optionsUseExtensionProviders": "Usar Provedores de Extensão", "@optionsUseExtensionProviders": { "description": "Enable extension download providers" }, - "optionsUseExtensionProvidersOn": "Extensions will be tried first", + "optionsUseExtensionProvidersOn": "Extensões serão tentadas primeiro", "@optionsUseExtensionProvidersOn": { "description": "Status when extension providers enabled" }, - "optionsUseExtensionProvidersOff": "Using built-in providers only", + "optionsUseExtensionProvidersOff": "Usando apenas provedores integrados", "@optionsUseExtensionProvidersOff": { "description": "Status when extension providers disabled" }, - "optionsEmbedLyrics": "Embed Lyrics", + "optionsEmbedLyrics": "Incorporar Letras", "@optionsEmbedLyrics": { "description": "Embed lyrics in audio files" }, - "optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files", + "optionsEmbedLyricsSubtitle": "Incorporar letras sincronizadas aos arquivos FLAC", "@optionsEmbedLyricsSubtitle": { "description": "Subtitle for embed lyrics" }, - "optionsMaxQualityCover": "Max Quality Cover", + "optionsMaxQualityCover": "Capa de Qualidade Máxima", "@optionsMaxQualityCover": { "description": "Download highest quality album art" }, - "optionsMaxQualityCoverSubtitle": "Download highest resolution cover art", + "optionsMaxQualityCoverSubtitle": "Baixar capa do álbum com a mais alta resolução", "@optionsMaxQualityCoverSubtitle": { "description": "Subtitle for max quality cover" }, - "optionsConcurrentDownloads": "Concurrent Downloads", + "optionsConcurrentDownloads": "Downloads Simultâneos", "@optionsConcurrentDownloads": { "description": "Number of parallel downloads" }, - "optionsConcurrentSequential": "Sequential (1 at a time)", + "optionsConcurrentSequential": "Sequencial (1 por vez)", "@optionsConcurrentSequential": { "description": "Download one at a time" }, - "optionsConcurrentParallel": "{count} parallel downloads", + "optionsConcurrentParallel": "{count} downloads paralelos", "@optionsConcurrentParallel": { "description": "Multiple parallel downloads", "placeholders": { @@ -345,63 +345,63 @@ } } }, - "optionsConcurrentWarning": "Parallel downloads may trigger rate limiting", + "optionsConcurrentWarning": "Downloads simultâneos podem causar um limite da taxa (ratelimit)", "@optionsConcurrentWarning": { "description": "Warning about rate limits" }, - "optionsExtensionStore": "Extension Store", + "optionsExtensionStore": "Loja de Extensões", "@optionsExtensionStore": { "description": "Show/hide store tab" }, - "optionsExtensionStoreSubtitle": "Show Store tab in navigation", + "optionsExtensionStoreSubtitle": "Mostrar aba da Loja na navegação", "@optionsExtensionStoreSubtitle": { "description": "Subtitle for extension store toggle" }, - "optionsCheckUpdates": "Check for Updates", + "optionsCheckUpdates": "Procurar Atualizações", "@optionsCheckUpdates": { "description": "Auto update check toggle" }, - "optionsCheckUpdatesSubtitle": "Notify when new version is available", + "optionsCheckUpdatesSubtitle": "Notificar quando uma nova versão estiver disponível", "@optionsCheckUpdatesSubtitle": { "description": "Subtitle for update check" }, - "optionsUpdateChannel": "Update Channel", + "optionsUpdateChannel": "Canal de Atualização", "@optionsUpdateChannel": { "description": "Stable vs preview releases" }, - "optionsUpdateChannelStable": "Stable releases only", + "optionsUpdateChannelStable": "Somente versões estáveis", "@optionsUpdateChannelStable": { "description": "Only stable updates" }, - "optionsUpdateChannelPreview": "Get preview releases", + "optionsUpdateChannelPreview": "Obter versões de prévia", "@optionsUpdateChannelPreview": { "description": "Include beta/preview updates" }, - "optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features", + "optionsUpdateChannelWarning": "A prévia pode conter erros ou recursos incompletos", "@optionsUpdateChannelWarning": { "description": "Warning about preview channel" }, - "optionsClearHistory": "Clear Download History", + "optionsClearHistory": "Limpar Histórico de Download", "@optionsClearHistory": { "description": "Delete all download history" }, - "optionsClearHistorySubtitle": "Remove all downloaded tracks from history", + "optionsClearHistorySubtitle": "Remover todas as faixas baixadas do histórico", "@optionsClearHistorySubtitle": { "description": "Subtitle for clear history" }, - "optionsDetailedLogging": "Detailed Logging", + "optionsDetailedLogging": "Registro detalhado", "@optionsDetailedLogging": { "description": "Enable verbose logs for debugging" }, - "optionsDetailedLoggingOn": "Detailed logs are being recorded", + "optionsDetailedLoggingOn": "Registros detalhados estão sendo gravados", "@optionsDetailedLoggingOn": { "description": "Status when logging enabled" }, - "optionsDetailedLoggingOff": "Enable for bug reports", + "optionsDetailedLoggingOff": "Habilitar para relatórios de erros", "@optionsDetailedLoggingOff": { "description": "Status when logging disabled" }, - "optionsSpotifyCredentials": "Spotify Credentials", + "optionsSpotifyCredentials": "Credenciais do Spotify", "@optionsSpotifyCredentials": { "description": "Spotify API credentials setting" }, @@ -414,39 +414,39 @@ } } }, - "optionsSpotifyCredentialsRequired": "Required - tap to configure", + "optionsSpotifyCredentialsRequired": "Obrigatório - toque para configurar", "@optionsSpotifyCredentialsRequired": { "description": "Prompt to set up credentials" }, - "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", + "optionsSpotifyWarning": "O Spotify requer as suas próprias credenciais de API. Consiga gratuitamente em developer.spotify.com", "@optionsSpotifyWarning": { "description": "Info about Spotify API requirement" }, - "extensionsTitle": "Extensions", + "extensionsTitle": "Extensões", "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", + "extensionsInstalled": "Extensões Instaladas", "@extensionsInstalled": { "description": "Section header for installed extensions" }, - "extensionsNone": "No extensions installed", + "extensionsNone": "Nenhuma extensão instalada", "@extensionsNone": { "description": "Empty state title" }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", + "extensionsNoneSubtitle": "Instalar extensões a partir da aba Loja", "@extensionsNoneSubtitle": { "description": "Empty state subtitle" }, - "extensionsEnabled": "Enabled", + "extensionsEnabled": "Habilitado", "@extensionsEnabled": { "description": "Extension status - active" }, - "extensionsDisabled": "Disabled", + "extensionsDisabled": "Desabilitado", "@extensionsDisabled": { "description": "Extension status - inactive" }, - "extensionsVersion": "Version {version}", + "extensionsVersion": "Versão {version}", "@extensionsVersion": { "description": "Extension version display", "placeholders": { @@ -455,7 +455,7 @@ } } }, - "extensionsAuthor": "by {author}", + "extensionsAuthor": "por {author}", "@extensionsAuthor": { "description": "Extension author credit", "placeholders": { @@ -464,55 +464,55 @@ } } }, - "extensionsUninstall": "Uninstall", + "extensionsUninstall": "Desinstalar", "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", + "extensionsSetAsSearch": "Definir como Provedor de Pesquisa", "@extensionsSetAsSearch": { "description": "Use extension for search" }, - "storeTitle": "Extension Store", + "storeTitle": "Loja de Extensões", "@storeTitle": { "description": "Store screen title" }, - "storeSearch": "Search extensions...", + "storeSearch": "Pesquisar extensões...", "@storeSearch": { "description": "Store search placeholder" }, - "storeInstall": "Install", + "storeInstall": "Instalar", "@storeInstall": { "description": "Install extension button" }, - "storeInstalled": "Installed", + "storeInstalled": "Instalado", "@storeInstalled": { "description": "Already installed badge" }, - "storeUpdate": "Update", + "storeUpdate": "Atualizar", "@storeUpdate": { "description": "Update available button" }, - "aboutTitle": "About", + "aboutTitle": "Sobre", "@aboutTitle": { "description": "About page title" }, - "aboutContributors": "Contributors", + "aboutContributors": "Colaboradores", "@aboutContributors": { "description": "Section for contributors" }, - "aboutMobileDeveloper": "Mobile version developer", + "aboutMobileDeveloper": "Desenvolvedor da versão móvel", "@aboutMobileDeveloper": { "description": "Role description for mobile dev" }, - "aboutOriginalCreator": "Creator of the original SpotiFLAC", + "aboutOriginalCreator": "Criador do SpotiFLAC original", "@aboutOriginalCreator": { "description": "Role description for original creator" }, - "aboutLogoArtist": "The talented artist who created our beautiful app logo!", + "aboutLogoArtist": "O artista talentoso que criou o nosso lindo logotipo do aplicativo!", "@aboutLogoArtist": { "description": "Role description for logo artist" }, - "aboutSpecialThanks": "Special Thanks", + "aboutSpecialThanks": "Agradecimentos Especiais", "@aboutSpecialThanks": { "description": "Section for special thanks" }, @@ -520,55 +520,55 @@ "@aboutLinks": { "description": "Section for external links" }, - "aboutMobileSource": "Mobile source code", + "aboutMobileSource": "Código-fonte do app móvel", "@aboutMobileSource": { "description": "Link to mobile GitHub repo" }, - "aboutPCSource": "PC source code", + "aboutPCSource": "Código-fonte do app desktop", "@aboutPCSource": { "description": "Link to PC GitHub repo" }, - "aboutReportIssue": "Report an issue", + "aboutReportIssue": "Reportar um problema", "@aboutReportIssue": { "description": "Link to report bugs" }, - "aboutReportIssueSubtitle": "Report any problems you encounter", + "aboutReportIssueSubtitle": "Reporte qualquer problema que encontrar", "@aboutReportIssueSubtitle": { "description": "Subtitle for report issue" }, - "aboutFeatureRequest": "Feature request", + "aboutFeatureRequest": "Solicitação de recurso", "@aboutFeatureRequest": { "description": "Link to suggest features" }, - "aboutFeatureRequestSubtitle": "Suggest new features for the app", + "aboutFeatureRequestSubtitle": "Sugira novos recursos para o aplicativo", "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, - "aboutSupport": "Support", + "aboutSupport": "Apoiar", "@aboutSupport": { "description": "Section for support/donation links" }, - "aboutBuyMeCoffee": "Buy me a coffee", + "aboutBuyMeCoffee": "Compre-me um café", "@aboutBuyMeCoffee": { "description": "Donation link" }, - "aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi", + "aboutBuyMeCoffeeSubtitle": "Apoie o desenvolvimento na Ko-fi", "@aboutBuyMeCoffeeSubtitle": { "description": "Subtitle for donation" }, - "aboutApp": "App", + "aboutApp": "Aplicativo", "@aboutApp": { "description": "Section for app info" }, - "aboutVersion": "Version", + "aboutVersion": "Versão", "@aboutVersion": { "description": "Version info label" }, - "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", + "aboutBinimumDesc": "O criador da API QQDL e HiFi. Sem esta API, os downloads Tidal não existiriam!", "@aboutBinimumDesc": { "description": "Credit description for binimum" }, - "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", + "aboutSachinsenalDesc": "O criador original do projeto HiFi. A base da integração do Tidal!", "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, @@ -576,7 +576,7 @@ "@aboutDoubleDouble": { "description": "Name of Amazon API service - DO NOT TRANSLATE" }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", + "aboutDoubleDoubleDesc": "API incrível para downloads do Amazon Music. Obrigado por fazê-lo gratuitamente!", "@aboutDoubleDoubleDesc": { "description": "Credit for DoubleDouble API" }, @@ -584,19 +584,19 @@ "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" }, - "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", + "aboutDabMusicDesc": "A melhor API de streaming do Qobuz. Downloads de alta resolução não seriam possíveis sem isso!", "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.", "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", + "albumTitle": "Álbum", "@albumTitle": { "description": "Album screen title" }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", + "albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", "@albumTracks": { "description": "Album track count", "placeholders": { @@ -605,11 +605,11 @@ } } }, - "albumDownloadAll": "Download All", + "albumDownloadAll": "Baixar Tudo", "@albumDownloadAll": { "description": "Button to download all tracks" }, - "albumDownloadRemaining": "Download Remaining", + "albumDownloadRemaining": "Downloads Restantes", "@albumDownloadRemaining": { "description": "Button to download remaining tracks" }, @@ -617,23 +617,23 @@ "@playlistTitle": { "description": "Playlist screen title" }, - "artistTitle": "Artist", + "artistTitle": "Artista", "@artistTitle": { "description": "Artist screen title" }, - "artistAlbums": "Albums", + "artistAlbums": "Álbuns", "@artistAlbums": { "description": "Section header for artist albums" }, - "artistSingles": "Singles & EPs", + "artistSingles": "Singles e EPs", "@artistSingles": { "description": "Section header for singles/EPs" }, - "artistCompilations": "Compilations", + "artistCompilations": "Compilações", "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", + "artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}", "@artistReleases": { "description": "Artist release count", "placeholders": { @@ -642,11 +642,11 @@ } } }, - "artistPopular": "Popular", + "artistPopular": "Populares", "@artistPopular": { "description": "Section header for popular/top tracks" }, - "artistMonthlyListeners": "{count} monthly listeners", + "artistMonthlyListeners": "{count} ouvintes mensais", "@artistMonthlyListeners": { "description": "Monthly listener count display", "placeholders": { @@ -656,123 +656,123 @@ } } }, - "trackMetadataTitle": "Track Info", + "trackMetadataTitle": "Informações da Faixa", "@trackMetadataTitle": { "description": "Track metadata screen title" }, - "trackMetadataArtist": "Artist", + "trackMetadataArtist": "Artista", "@trackMetadataArtist": { "description": "Metadata field - artist name" }, - "trackMetadataAlbum": "Album", + "trackMetadataAlbum": "Álbum", "@trackMetadataAlbum": { "description": "Metadata field - album name" }, - "trackMetadataDuration": "Duration", + "trackMetadataDuration": "Duração", "@trackMetadataDuration": { "description": "Metadata field - track length" }, - "trackMetadataQuality": "Quality", + "trackMetadataQuality": "Qualidade", "@trackMetadataQuality": { "description": "Metadata field - audio quality" }, - "trackMetadataPath": "File Path", + "trackMetadataPath": "Caminho do Arquivo", "@trackMetadataPath": { "description": "Metadata field - file location" }, - "trackMetadataDownloadedAt": "Downloaded", + "trackMetadataDownloadedAt": "Baixado", "@trackMetadataDownloadedAt": { "description": "Metadata field - download date" }, - "trackMetadataService": "Service", + "trackMetadataService": "Serviço", "@trackMetadataService": { "description": "Metadata field - download service used" }, - "trackMetadataPlay": "Play", + "trackMetadataPlay": "Reproduzir", "@trackMetadataPlay": { "description": "Action button - play track" }, - "trackMetadataShare": "Share", + "trackMetadataShare": "Compartilhar", "@trackMetadataShare": { "description": "Action button - share track" }, - "trackMetadataDelete": "Delete", + "trackMetadataDelete": "Apagar", "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", + "trackMetadataRedownload": "Baixar Novamente", "@trackMetadataRedownload": { "description": "Action button - download again" }, - "trackMetadataOpenFolder": "Open Folder", + "trackMetadataOpenFolder": "Abrir Pasta", "@trackMetadataOpenFolder": { "description": "Action button - open containing folder" }, - "setupTitle": "Welcome to SpotiFLAC", + "setupTitle": "Bem-vindo ao SpotiFLAC", "@setupTitle": { "description": "Setup wizard title" }, - "setupSubtitle": "Let's get you started", + "setupSubtitle": "Vamos começar", "@setupSubtitle": { "description": "Setup wizard subtitle" }, - "setupStoragePermission": "Storage Permission", + "setupStoragePermission": "Permissão de Armazenamento", "@setupStoragePermission": { "description": "Storage permission step title" }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", + "setupStoragePermissionSubtitle": "Necessária para salvar arquivos baixados", "@setupStoragePermissionSubtitle": { "description": "Explanation for storage permission" }, - "setupStoragePermissionGranted": "Permission granted", + "setupStoragePermissionGranted": "Permissão concedida", "@setupStoragePermissionGranted": { "description": "Status when permission granted" }, - "setupStoragePermissionDenied": "Permission denied", + "setupStoragePermissionDenied": "Permissão negada", "@setupStoragePermissionDenied": { "description": "Status when permission denied" }, - "setupGrantPermission": "Grant Permission", + "setupGrantPermission": "Conceder Permissão", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", + "setupDownloadLocation": "Local do Download", "@setupDownloadLocation": { "description": "Download folder step title" }, - "setupChooseFolder": "Choose Folder", + "setupChooseFolder": "Selecionar Pasta", "@setupChooseFolder": { "description": "Button to pick folder" }, - "setupContinue": "Continue", + "setupContinue": "Continuar", "@setupContinue": { "description": "Continue to next step button" }, - "setupSkip": "Skip for now", + "setupSkip": "Ignorar por enquanto", "@setupSkip": { "description": "Skip current step button" }, - "setupStorageAccessRequired": "Storage Access Required", + "setupStorageAccessRequired": "Acesso ao Armazenamento Necessário", "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", + "setupStorageAccessMessage": "O SpotiFLAC precisa da permissão \"Acesso a todos os arquivos\" para salvar arquivos de música na sua pasta escolhida.", "@setupStorageAccessMessage": { "description": "Explanation for storage access" }, - "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", + "setupStorageAccessMessageAndroid11": "O Android 11+ requer a permissão \"Acesso a Todos os Arquivos\" para salvar arquivos na pasta de download escolhida.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" }, - "setupOpenSettings": "Open Settings", + "setupOpenSettings": "Abrir Configurações", "@setupOpenSettings": { "description": "Button to open system settings" }, - "setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.", + "setupPermissionDeniedMessage": "Permissão negada. Por favor, conceda todas as permissões para continuar.", "@setupPermissionDeniedMessage": { "description": "Error when permission denied" }, - "setupPermissionRequired": "{permissionType} Permission Required", + "setupPermissionRequired": "Permissão {permissionType} Necessária", "@setupPermissionRequired": { "description": "Generic permission required title", "placeholders": { @@ -782,7 +782,7 @@ } } }, - "setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.", + "setupPermissionRequiredMessage": "A permissão {permissionType} é necessária para a melhor experiência. Você pode alterar isso mais tarde em Configurações.", "@setupPermissionRequiredMessage": { "description": "Generic permission required message", "placeholders": { @@ -791,47 +791,47 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", + "setupSelectDownloadFolder": "Escolher Pasta de Download", "@setupSelectDownloadFolder": { "description": "Folder selection step title" }, - "setupUseDefaultFolder": "Use Default Folder?", + "setupUseDefaultFolder": "Usar Pasta Padrão?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" }, - "setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?", + "setupNoFolderSelected": "Nenhuma pasta selecionada. Você gostaria de usar a pasta padrão de música?", "@setupNoFolderSelected": { "description": "Prompt when no folder selected" }, - "setupUseDefault": "Use Default", + "setupUseDefault": "Usar Padrão", "@setupUseDefault": { "description": "Button to use default folder" }, - "setupDownloadLocationTitle": "Download Location", + "setupDownloadLocationTitle": "Local do Download", "@setupDownloadLocationTitle": { "description": "Download location dialog title" }, - "setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.", + "setupDownloadLocationIosMessage": "No iOS, downloads são salvos na pasta Documentos do aplicativo. Você pode acessá-los através do app Arquivos.", "@setupDownloadLocationIosMessage": { "description": "iOS-specific folder info" }, - "setupAppDocumentsFolder": "App Documents Folder", + "setupAppDocumentsFolder": "Pasta Documentos do App", "@setupAppDocumentsFolder": { "description": "iOS documents folder option" }, - "setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app", + "setupAppDocumentsFolderSubtitle": "Recomendado - acessível através do aplicativo Arquivos", "@setupAppDocumentsFolderSubtitle": { "description": "Subtitle for documents folder" }, - "setupChooseFromFiles": "Choose from Files", + "setupChooseFromFiles": "Escolher dos Arquivos", "@setupChooseFromFiles": { "description": "iOS file picker option" }, - "setupChooseFromFilesSubtitle": "Select iCloud or other location", + "setupChooseFromFilesSubtitle": "Selecione o iCloud ou outro local", "@setupChooseFromFilesSubtitle": { "description": "Subtitle for file picker" }, - "setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", + "setupIosEmptyFolderWarning": "Limitação do iOS: Pastas vazias não podem ser selecionadas. Escolha uma pasta com pelo menos um arquivo.", "@setupIosEmptyFolderWarning": { "description": "iOS folder selection warning" }, @@ -871,115 +871,115 @@ "@setupStorageDescription": { "description": "Explanation for storage permission" }, - "setupNotificationGranted": "Notification Permission Granted!", + "setupNotificationGranted": "Permissão de Notificações Concedida!", "@setupNotificationGranted": { "description": "Success message for notification permission" }, - "setupNotificationEnable": "Enable Notifications", + "setupNotificationEnable": "Habilitar Notificações", "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", + "setupNotificationDescription": "Seja notificado quando os downloads completarem ou exigirem atenção.", "@setupNotificationDescription": { "description": "Explanation for notifications" }, - "setupFolderSelected": "Download Folder Selected!", + "setupFolderSelected": "Pasta para Download Selecionada!", "@setupFolderSelected": { "description": "Success message for folder selection" }, - "setupFolderChoose": "Choose Download Folder", + "setupFolderChoose": "Escolher Pasta de Download", "@setupFolderChoose": { "description": "Button to choose folder" }, - "setupFolderDescription": "Select a folder where your downloaded music will be saved.", + "setupFolderDescription": "Selecione uma pasta onde as suas músicas baixadas serão salvas.", "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", + "setupChangeFolder": "Alterar Pasta", "@setupChangeFolder": { "description": "Button to change selected folder" }, - "setupSelectFolder": "Select Folder", + "setupSelectFolder": "Seleccionar Pasta", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", + "setupSpotifyApiOptional": "API do Spotify (opcional)", "@setupSpotifyApiOptional": { "description": "Spotify API step title" }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", + "setupSpotifyApiDescription": "Adicione as suas credenciais da API do Spotify para obter melhores resultados de busca e acesso a conteúdo exclusivo do Spotify.", "@setupSpotifyApiDescription": { "description": "Explanation for Spotify API" }, - "setupUseSpotifyApi": "Use Spotify API", + "setupUseSpotifyApi": "Usar API do Spotify", "@setupUseSpotifyApi": { "description": "Toggle to enable Spotify API" }, - "setupEnterCredentialsBelow": "Enter your credentials below", + "setupEnterCredentialsBelow": "Insira as suas credenciais abaixo", "@setupEnterCredentialsBelow": { "description": "Prompt to enter credentials" }, - "setupUsingDeezer": "Using Deezer (no account needed)", + "setupUsingDeezer": "Usando o Deezer (nenhuma conta necessária)", "@setupUsingDeezer": { "description": "Status when using Deezer" }, - "setupEnterClientId": "Enter Spotify Client ID", + "setupEnterClientId": "Insira o Spotify Client ID", "@setupEnterClientId": { "description": "Placeholder for client ID field" }, - "setupEnterClientSecret": "Enter Spotify Client Secret", + "setupEnterClientSecret": "Insira o Spotify Client Secret", "@setupEnterClientSecret": { "description": "Placeholder for client secret field" }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", + "setupGetFreeCredentials": "Receba as suas credenciais de API gratuitas na Spotify Developer Dashboard.", "@setupGetFreeCredentials": { "description": "Info about getting Spotify credentials" }, - "setupEnableNotifications": "Enable Notifications", + "setupEnableNotifications": "Habilitar Notificações", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", + "setupProceedToNextStep": "Você já pode prosseguir para o próximo passo.", "@setupProceedToNextStep": { "description": "Message after completing a step" }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", + "setupNotificationProgressDescription": "Você receberá notificações de progresso dos downloads.", "@setupNotificationProgressDescription": { "description": "Info about notification usage" }, - "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", + "setupNotificationBackgroundDescription": "Seja notificado sobre o progresso e conclusão do download. Isso ajuda você a acompanhar os downloads quando o app estiver em segundo plano.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" }, - "setupSkipForNow": "Skip for now", + "setupSkipForNow": "Ignorar por enquanto", "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", + "setupBack": "Voltar", "@setupBack": { "description": "Back button text" }, - "setupNext": "Next", + "setupNext": "Próximo", "@setupNext": { "description": "Next button text" }, - "setupGetStarted": "Get Started", + "setupGetStarted": "Começar", "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", + "setupSkipAndStart": "Ignorar e Iniciar", "@setupSkipAndStart": { "description": "Skip setup and start app" }, - "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", + "setupAllowAccessToManageFiles": "Por favor, habilite \"Permitir acesso para gerenciar todos os arquivos\" na próxima tela.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", + "setupGetCredentialsFromSpotify": "Obter credenciais do developer.spotify.com", "@setupGetCredentialsFromSpotify": { "description": "Link text for Spotify developer portal" }, - "dialogCancel": "Cancel", + "dialogCancel": "Cancelar", "@dialogCancel": { "description": "Dialog button - cancel action" }, @@ -987,87 +987,87 @@ "@dialogOk": { "description": "Dialog button - confirm/acknowledge" }, - "dialogSave": "Save", + "dialogSave": "Salvar", "@dialogSave": { "description": "Dialog button - save changes" }, - "dialogDelete": "Delete", + "dialogDelete": "Apagar", "@dialogDelete": { "description": "Dialog button - delete item" }, - "dialogRetry": "Retry", + "dialogRetry": "Tentar novamente", "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", + "dialogClose": "Fechar", "@dialogClose": { "description": "Dialog button - close dialog" }, - "dialogYes": "Yes", + "dialogYes": "Sim", "@dialogYes": { "description": "Dialog button - confirm yes" }, - "dialogNo": "No", + "dialogNo": "Não", "@dialogNo": { "description": "Dialog button - confirm no" }, - "dialogClear": "Clear", + "dialogClear": "Limpar", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", + "dialogConfirm": "Confirmar", "@dialogConfirm": { "description": "Dialog button - confirm action" }, - "dialogDone": "Done", + "dialogDone": "Concluído", "@dialogDone": { "description": "Dialog button - action completed" }, - "dialogImport": "Import", + "dialogImport": "Importar", "@dialogImport": { "description": "Dialog button - import data" }, - "dialogDiscard": "Discard", + "dialogDiscard": "Descartar", "@dialogDiscard": { "description": "Dialog button - discard changes" }, - "dialogRemove": "Remove", + "dialogRemove": "Remover", "@dialogRemove": { "description": "Dialog button - remove item" }, - "dialogUninstall": "Uninstall", + "dialogUninstall": "Desinstalar", "@dialogUninstall": { "description": "Dialog button - uninstall extension" }, - "dialogDiscardChanges": "Discard Changes?", + "dialogDiscardChanges": "Descartar Alterações?", "@dialogDiscardChanges": { "description": "Dialog title - unsaved changes warning" }, - "dialogUnsavedChanges": "You have unsaved changes. Do you want to discard them?", + "dialogUnsavedChanges": "Você tem alterações não salvas. Deseja descartá-las?", "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", + "dialogDownloadFailed": "Download Falhou", "@dialogDownloadFailed": { "description": "Dialog title - download error" }, - "dialogTrackLabel": "Track:", + "dialogTrackLabel": "Faixa:", "@dialogTrackLabel": { "description": "Label for track name in error dialog" }, - "dialogArtistLabel": "Artist:", + "dialogArtistLabel": "Artista:", "@dialogArtistLabel": { "description": "Label for artist name in error dialog" }, - "dialogErrorLabel": "Error:", + "dialogErrorLabel": "Erro:", "@dialogErrorLabel": { "description": "Label for error message" }, - "dialogClearAll": "Clear All", + "dialogClearAll": "Limpar Tudo", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", + "dialogClearAllDownloads": "Você tem certeza que deseja limpar todos os downloads?", "@dialogClearAllDownloads": { "description": "Dialog message - clear downloads confirmation" }, @@ -1307,59 +1307,59 @@ "@statusFailed": { "description": "Download status - error occurred" }, - "statusSkipped": "Skipped", + "statusSkipped": "Ignorado", "@statusSkipped": { "description": "Download status - already exists" }, - "statusPaused": "Paused", + "statusPaused": "Pausado", "@statusPaused": { "description": "Download status - paused" }, - "actionPause": "Pause", + "actionPause": "Pausar", "@actionPause": { "description": "Action button - pause download" }, - "actionResume": "Resume", + "actionResume": "Retomar", "@actionResume": { "description": "Action button - resume download" }, - "actionCancel": "Cancel", + "actionCancel": "Cancelar", "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", + "actionStop": "Parar", "@actionStop": { "description": "Action button - stop operation" }, - "actionSelect": "Select", + "actionSelect": "Selecionar", "@actionSelect": { "description": "Action button - enter selection mode" }, - "actionSelectAll": "Select All", + "actionSelectAll": "Selecionar Tudo", "@actionSelectAll": { "description": "Action button - select all items" }, - "actionDeselect": "Deselect", + "actionDeselect": "Desselecionar", "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", + "actionPaste": "Colar", "@actionPaste": { "description": "Action button - paste from clipboard" }, - "actionImportCsv": "Import CSV", + "actionImportCsv": "Importar CSV", "@actionImportCsv": { "description": "Action button - import CSV file" }, - "actionRemoveCredentials": "Remove Credentials", + "actionRemoveCredentials": "Remover Credenciais", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" }, - "actionSaveCredentials": "Save Credentials", + "actionSaveCredentials": "Salvar Credenciais", "@actionSaveCredentials": { "description": "Action button - save Spotify credentials" }, - "selectionSelected": "{count} selected", + "selectionSelected": "{count} selecionado(s)", "@selectionSelected": { "description": "Selection count indicator", "placeholders": { @@ -1368,15 +1368,15 @@ } } }, - "selectionAllSelected": "All tracks selected", + "selectionAllSelected": "Todas as faixas selecionadas", "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", + "selectionTapToSelect": "Toque nas faixas para selecionar", "@selectionTapToSelect": { "description": "Hint - how to select items" }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", + "selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}", "@selectionDeleteTracks": { "description": "Delete button with count", "placeholders": { @@ -1385,11 +1385,11 @@ } } }, - "selectionSelectToDelete": "Select tracks to delete", + "selectionSelectToDelete": "Selecione as faixas para apagar", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" }, - "progressFetchingMetadata": "Fetching metadata... {current}/{total}", + "progressFetchingMetadata": "Buscando metadados... {current}/{total}", "@progressFetchingMetadata": { "description": "Progress indicator - loading track info", "placeholders": { @@ -1401,19 +1401,19 @@ } } }, - "progressReadingCsv": "Reading CSV...", + "progressReadingCsv": "Lendo CSV...", "@progressReadingCsv": { "description": "Progress indicator - parsing CSV file" }, - "searchSongs": "Songs", + "searchSongs": "Músicas", "@searchSongs": { "description": "Search result category - songs" }, - "searchArtists": "Artists", + "searchArtists": "Artistas", "@searchArtists": { "description": "Search result category - artists" }, - "searchAlbums": "Albums", + "searchAlbums": "Álbuns", "@searchAlbums": { "description": "Search result category - albums" }, @@ -1421,39 +1421,39 @@ "@searchPlaylists": { "description": "Search result category - playlists" }, - "tooltipPlay": "Play", + "tooltipPlay": "Reproduzir", "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", + "tooltipCancel": "Cancelar", "@tooltipCancel": { "description": "Tooltip - cancel button" }, - "tooltipStop": "Stop", + "tooltipStop": "Parar", "@tooltipStop": { "description": "Tooltip - stop button" }, - "tooltipRetry": "Retry", + "tooltipRetry": "Tentar Novamente", "@tooltipRetry": { "description": "Tooltip - retry button" }, - "tooltipRemove": "Remove", + "tooltipRemove": "Remover", "@tooltipRemove": { "description": "Tooltip - remove button" }, - "tooltipClear": "Clear", + "tooltipClear": "Limpar", "@tooltipClear": { "description": "Tooltip - clear button" }, - "tooltipPaste": "Paste", + "tooltipPaste": "Colar", "@tooltipPaste": { "description": "Tooltip - paste button" }, - "filenameFormat": "Filename Format", + "filenameFormat": "Formato do Nome do Arquivo", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", + "filenameFormatPreview": "Prévia: {preview}", "@filenameFormatPreview": { "description": "Preview of filename pattern", "placeholders": { @@ -1462,7 +1462,7 @@ } } }, - "filenameAvailablePlaceholders": "Available placeholders:", + "filenameAvailablePlaceholders": "Substituições permitidas:", "@filenameAvailablePlaceholders": { "description": "Label for placeholder list" }, @@ -1470,51 +1470,51 @@ "@filenameHint": { "description": "Default filename format hint" }, - "folderOrganization": "Folder Organization", + "folderOrganization": "Organização de Pastas", "@folderOrganization": { "description": "Setting title - folder structure" }, - "folderOrganizationNone": "No organization", + "folderOrganizationNone": "Nenhuma organização", "@folderOrganizationNone": { "description": "Folder option - flat structure" }, - "folderOrganizationByArtist": "By Artist", + "folderOrganizationByArtist": "Por Artista", "@folderOrganizationByArtist": { "description": "Folder option - artist folders" }, - "folderOrganizationByAlbum": "By Album", + "folderOrganizationByAlbum": "Por Album", "@folderOrganizationByAlbum": { "description": "Folder option - album folders" }, - "folderOrganizationByArtistAlbum": "Artist/Album", + "folderOrganizationByArtistAlbum": "Artista/Álbum", "@folderOrganizationByArtistAlbum": { "description": "Folder option - nested folders" }, - "folderOrganizationDescription": "Organize downloaded files into folders", + "folderOrganizationDescription": "Organizar arquivos baixados em pastas", "@folderOrganizationDescription": { "description": "Folder organization sheet description" }, - "folderOrganizationNoneSubtitle": "All files in download folder", + "folderOrganizationNoneSubtitle": "Todos os arquivos na pasta de download", "@folderOrganizationNoneSubtitle": { "description": "Subtitle for no organization option" }, - "folderOrganizationByArtistSubtitle": "Separate folder for each artist", + "folderOrganizationByArtistSubtitle": "Pasta separada para cada artista", "@folderOrganizationByArtistSubtitle": { "description": "Subtitle for artist folder option" }, - "folderOrganizationByAlbumSubtitle": "Separate folder for each album", + "folderOrganizationByAlbumSubtitle": "Pasta separada para cada álbum", "@folderOrganizationByAlbumSubtitle": { "description": "Subtitle for album folder option" }, - "folderOrganizationByArtistAlbumSubtitle": "Nested folders for artist and album", + "folderOrganizationByArtistAlbumSubtitle": "Pastas aninhadas para artista e álbum", "@folderOrganizationByArtistAlbumSubtitle": { "description": "Subtitle for nested folder option" }, - "updateAvailable": "Update Available", + "updateAvailable": "Atualização Disponível", "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", + "updateNewVersion": "A versão {version} está disponível", "@updateNewVersion": { "description": "Update available message", "placeholders": { @@ -1523,215 +1523,215 @@ } } }, - "updateDownload": "Download", + "updateDownload": "Baixar", "@updateDownload": { "description": "Update button - download update" }, - "updateLater": "Later", + "updateLater": "Depois", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", + "updateChangelog": "Lista de alterações", "@updateChangelog": { "description": "Link to changelog" }, - "updateStartingDownload": "Starting download...", + "updateStartingDownload": "Iniciando download...", "@updateStartingDownload": { "description": "Update status - initializing" }, - "updateDownloadFailed": "Download failed", + "updateDownloadFailed": "Download falhou", "@updateDownloadFailed": { "description": "Update error title" }, - "updateFailedMessage": "Failed to download update", + "updateFailedMessage": "Falha ao baixar a atualização", "@updateFailedMessage": { "description": "Update error message" }, - "updateNewVersionReady": "A new version is ready", + "updateNewVersionReady": "Uma nova versão está pronta", "@updateNewVersionReady": { "description": "Update subtitle" }, - "updateCurrent": "Current", + "updateCurrent": "Atual", "@updateCurrent": { "description": "Label for current version" }, - "updateNew": "New", + "updateNew": "Novo", "@updateNew": { "description": "Label for new version" }, - "updateDownloading": "Downloading...", + "updateDownloading": "Baixando...", "@updateDownloading": { "description": "Update status - downloading" }, - "updateWhatsNew": "What's New", + "updateWhatsNew": "Novidades", "@updateWhatsNew": { "description": "Changelog section title" }, - "updateDownloadInstall": "Download & Install", + "updateDownloadInstall": "Baixar e Instalar", "@updateDownloadInstall": { "description": "Update button - download and install" }, - "updateDontRemind": "Don't remind", + "updateDontRemind": "Não lembrar", "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", + "providerPriority": "Prioridade de Provedor", "@providerPriority": { "description": "Setting title - download provider order" }, - "providerPrioritySubtitle": "Drag to reorder download providers", + "providerPrioritySubtitle": "Arraste para reordenar os provedores de download", "@providerPrioritySubtitle": { "description": "Subtitle for provider priority" }, - "providerPriorityTitle": "Provider Priority", + "providerPriorityTitle": "Prioridade de Provedor", "@providerPriorityTitle": { "description": "Provider priority page title" }, - "providerPriorityDescription": "Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.", + "providerPriorityDescription": "Arraste para reordenar provedores de download. O aplicativo irá tentar provedores de cima para baixo ao baixar as faixas.", "@providerPriorityDescription": { "description": "Provider priority page description" }, - "providerPriorityInfo": "If a track is not available on the first provider, the app will automatically try the next one.", + "providerPriorityInfo": "Se uma faixa não estiver disponível no primeiro provedor, o aplicativo irá tentar automaticamente a próxima.", "@providerPriorityInfo": { "description": "Info tip about fallback behavior" }, - "providerBuiltIn": "Built-in", + "providerBuiltIn": "Embutido", "@providerBuiltIn": { "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" }, - "providerExtension": "Extension", + "providerExtension": "Extensão", "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", + "metadataProviderPriority": "Prioridade de Provedor de Metadados", "@metadataProviderPriority": { "description": "Setting title - metadata provider order" }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", + "metadataProviderPrioritySubtitle": "Ordem usada para obter metadados de faixa", "@metadataProviderPrioritySubtitle": { "description": "Subtitle for metadata priority" }, - "metadataProviderPriorityTitle": "Metadata Priority", + "metadataProviderPriorityTitle": "Prioridade de Metadados", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" }, - "metadataProviderPriorityDescription": "Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.", + "metadataProviderPriorityDescription": "Arraste para reordenar provedores de metadados. O aplicativo tentará provedores de cima para baixo ao procurar por faixas e buscar metadados.", "@metadataProviderPriorityDescription": { "description": "Metadata priority page description" }, - "metadataProviderPriorityInfo": "Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.", + "metadataProviderPriorityInfo": "O Deezer não tem limites de taxa e é recomendado como principal. O Spotify pode limitar a taxa após muitas solicitações.", "@metadataProviderPriorityInfo": { "description": "Info tip about rate limits" }, - "metadataNoRateLimits": "No rate limits", + "metadataNoRateLimits": "Sem limites de taxa", "@metadataNoRateLimits": { "description": "Deezer provider description" }, - "metadataMayRateLimit": "May rate limit", + "metadataMayRateLimit": "Pode ter limites de taxa", "@metadataMayRateLimit": { "description": "Spotify provider description" }, - "logTitle": "Logs", + "logTitle": "Registros", "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", + "logCopy": "Copiar Registros", "@logCopy": { "description": "Action - copy logs to clipboard" }, - "logClear": "Clear Logs", + "logClear": "Limpar Registros", "@logClear": { "description": "Action - delete all logs" }, - "logShare": "Share Logs", + "logShare": "Compartilhar Registros", "@logShare": { "description": "Action - share logs file" }, - "logEmpty": "No logs yet", + "logEmpty": "Ainda não há registros", "@logEmpty": { "description": "Empty state title" }, - "logCopied": "Logs copied to clipboard", + "logCopied": "Registros copiados para área de transferência", "@logCopied": { "description": "Snackbar - logs copied" }, - "logSearchHint": "Search logs...", + "logSearchHint": "Pesquisar registros...", "@logSearchHint": { "description": "Log search placeholder" }, - "logFilterLevel": "Level", + "logFilterLevel": "Nível", "@logFilterLevel": { "description": "Filter by log level" }, - "logFilterSection": "Filter", + "logFilterSection": "Filtro", "@logFilterSection": { "description": "Filter section title" }, - "logShareLogs": "Share logs", + "logShareLogs": "Compartilhar registros", "@logShareLogs": { "description": "Share button tooltip" }, - "logClearLogs": "Clear logs", + "logClearLogs": "Limpar registros", "@logClearLogs": { "description": "Clear button tooltip" }, - "logClearLogsTitle": "Clear Logs", + "logClearLogsTitle": "Limpar Registros", "@logClearLogsTitle": { "description": "Clear logs dialog title" }, - "logClearLogsMessage": "Are you sure you want to clear all logs?", + "logClearLogsMessage": "Tem certeza de que deseja limpar todos os registros?", "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", + "logIspBlocking": "BLOQUEIO DE ISP DETECTADO", "@logIspBlocking": { "description": "Error category - ISP blocking" }, - "logRateLimited": "RATE LIMITED", + "logRateLimited": "TAXA LIMITADA (RATELIMITED)", "@logRateLimited": { "description": "Error category - rate limiting" }, - "logNetworkError": "NETWORK ERROR", + "logNetworkError": "ERRO DE REDE", "@logNetworkError": { "description": "Error category - network issues" }, - "logTrackNotFound": "TRACK NOT FOUND", + "logTrackNotFound": "FAIXA NÃO ENCONTRADA", "@logTrackNotFound": { "description": "Error category - missing tracks" }, - "logFilterBySeverity": "Filter logs by severity", + "logFilterBySeverity": "Filtrar registros por gravidade", "@logFilterBySeverity": { "description": "Filter dialog title" }, - "logNoLogsYet": "No logs yet", + "logNoLogsYet": "Ainda não há registros", "@logNoLogsYet": { "description": "Empty state title" }, - "logNoLogsYetSubtitle": "Logs will appear here as you use the app", + "logNoLogsYetSubtitle": "Os registros aparecerão aqui enquanto você usa o aplicativo", "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", + "logIssueSummary": "Resumo do Problemas", "@logIssueSummary": { "description": "Section header for error summary" }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", + "logIspBlockingDescription": "O seu provedor pode estar bloqueando o acesso aos serviços de download", "@logIspBlockingDescription": { "description": "ISP blocking explanation" }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", + "logIspBlockingSuggestion": "Tente usar uma VPN ou altere o DNS para 1.1.1 ou 8.8.8.8", "@logIspBlockingSuggestion": { "description": "ISP blocking fix suggestion" }, - "logRateLimitedDescription": "Too many requests to the service", + "logRateLimitedDescription": "Muitas solicitações ao serviço", "@logRateLimitedDescription": { "description": "Rate limit explanation" }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", + "logRateLimitedSuggestion": "Aguarde alguns minutos antes de tentar novamente", "@logRateLimitedSuggestion": { "description": "Rate limit fix suggestion" }, - "logNetworkErrorDescription": "Connection issues detected", + "logNetworkErrorDescription": "Problemas de conexão detectados", "@logNetworkErrorDescription": { "description": "Network error explanation" }, @@ -1795,7 +1795,7 @@ "@credentialsClientId": { "description": "Client ID field label - DO NOT TRANSLATE" }, - "credentialsClientIdHint": "Paste Client ID", + "credentialsClientIdHint": "Colar Client ID", "@credentialsClientIdHint": { "description": "Client ID placeholder" }, @@ -1803,19 +1803,19 @@ "@credentialsClientSecret": { "description": "Client Secret field label - DO NOT TRANSLATE" }, - "credentialsClientSecretHint": "Paste Client Secret", + "credentialsClientSecretHint": "Colar Client Secret", "@credentialsClientSecretHint": { "description": "Client Secret placeholder" }, - "channelStable": "Stable", + "channelStable": "Estável", "@channelStable": { "description": "Update channel - stable releases" }, - "channelPreview": "Preview", + "channelPreview": "Prévia", "@channelPreview": { "description": "Update channel - beta/preview releases" }, - "sectionSearchSource": "Search Source", + "sectionSearchSource": "Origem da Pesquisa", "@sectionSearchSource": { "description": "Settings section header" }, @@ -1823,39 +1823,39 @@ "@sectionDownload": { "description": "Settings section header" }, - "sectionPerformance": "Performance", + "sectionPerformance": "Desempenho", "@sectionPerformance": { "description": "Settings section header" }, - "sectionApp": "App", + "sectionApp": "Aplicativo", "@sectionApp": { "description": "Settings section header" }, - "sectionData": "Data", + "sectionData": "Dados", "@sectionData": { "description": "Settings section header" }, - "sectionDebug": "Debug", + "sectionDebug": "Depuração", "@sectionDebug": { "description": "Settings section header" }, - "sectionService": "Service", + "sectionService": "Serviço", "@sectionService": { "description": "Settings section header" }, - "sectionAudioQuality": "Audio Quality", + "sectionAudioQuality": "Qualidade de Áudio", "@sectionAudioQuality": { "description": "Settings section header" }, - "sectionFileSettings": "File Settings", + "sectionFileSettings": "Configurações de Arquivo", "@sectionFileSettings": { "description": "Settings section header" }, - "sectionColor": "Color", + "sectionColor": "Cor", "@sectionColor": { "description": "Settings section header" }, - "sectionTheme": "Theme", + "sectionTheme": "Tema", "@sectionTheme": { "description": "Settings section header" }, @@ -1863,51 +1863,51 @@ "@sectionLayout": { "description": "Settings section header" }, - "sectionLanguage": "Language", + "sectionLanguage": "Idioma", "@sectionLanguage": { "description": "Settings section header for language" }, - "appearanceLanguage": "App Language", + "appearanceLanguage": "Idioma do aplicativo", "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", + "appearanceLanguageSubtitle": "Escolha o seu idioma preferido", "@appearanceLanguageSubtitle": { "description": "Language setting subtitle" }, - "settingsAppearanceSubtitle": "Theme, colors, display", + "settingsAppearanceSubtitle": "Tema, cores, exibição", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" }, - "settingsDownloadSubtitle": "Service, quality, filename format", + "settingsDownloadSubtitle": "Serviço, qualidade, formato de nome de arquivo", "@settingsDownloadSubtitle": { "description": "Download settings description" }, - "settingsOptionsSubtitle": "Fallback, lyrics, cover art, updates", + "settingsOptionsSubtitle": "Fallback, letras, arte de capa, atualizações", "@settingsOptionsSubtitle": { "description": "Options settings description" }, - "settingsExtensionsSubtitle": "Manage download providers", + "settingsExtensionsSubtitle": "Gerenciar provedores de download", "@settingsExtensionsSubtitle": { "description": "Extensions settings description" }, - "settingsLogsSubtitle": "View app logs for debugging", + "settingsLogsSubtitle": "Ver logs do app para depuração", "@settingsLogsSubtitle": { "description": "Logs settings description" }, - "loadingSharedLink": "Loading shared link...", + "loadingSharedLink": "Carregando link compartilhado...", "@loadingSharedLink": { "description": "Status when opening shared URL" }, - "pressBackAgainToExit": "Press back again to exit", + "pressBackAgainToExit": "Pressione voltar novamente para sair", "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", + "tracksHeader": "Faixas", "@tracksHeader": { "description": "Section header for track list" }, - "downloadAllCount": "Download All ({count})", + "downloadAllCount": "Baixar Todos ({count})", "@downloadAllCount": { "description": "Download all button with count", "placeholders": { @@ -1916,7 +1916,7 @@ } } }, - "tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -1925,79 +1925,79 @@ } } }, - "trackCopyFilePath": "Copy file path", + "trackCopyFilePath": "Copiar caminho do arquivo", "@trackCopyFilePath": { "description": "Action - copy file path" }, - "trackRemoveFromDevice": "Remove from device", + "trackRemoveFromDevice": "Remover do dispositivo", "@trackRemoveFromDevice": { "description": "Action - delete downloaded file" }, - "trackLoadLyrics": "Load Lyrics", + "trackLoadLyrics": "Carregar Letras", "@trackLoadLyrics": { "description": "Action - fetch lyrics" }, - "trackMetadata": "Metadata", + "trackMetadata": "Metadados", "@trackMetadata": { "description": "Tab title - track metadata" }, - "trackFileInfo": "File Info", + "trackFileInfo": "Informações do Arquivo", "@trackFileInfo": { "description": "Tab title - file information" }, - "trackLyrics": "Lyrics", + "trackLyrics": "Letras", "@trackLyrics": { "description": "Tab title - lyrics" }, - "trackFileNotFound": "File not found", + "trackFileNotFound": "Arquivo não encontrado", "@trackFileNotFound": { "description": "Error - file doesn't exist" }, - "trackOpenInDeezer": "Open in Deezer", + "trackOpenInDeezer": "Abrir no Deezer", "@trackOpenInDeezer": { "description": "Action - open track in Deezer app" }, - "trackOpenInSpotify": "Open in Spotify", + "trackOpenInSpotify": "Abrir no Spotify", "@trackOpenInSpotify": { "description": "Action - open track in Spotify app" }, - "trackTrackName": "Track name", + "trackTrackName": "Nome da faixa", "@trackTrackName": { "description": "Metadata label - track title" }, - "trackArtist": "Artist", + "trackArtist": "Artista", "@trackArtist": { "description": "Metadata label - artist name" }, - "trackAlbumArtist": "Album artist", + "trackAlbumArtist": "Artista do álbum", "@trackAlbumArtist": { "description": "Metadata label - album artist" }, - "trackAlbum": "Album", + "trackAlbum": "Álbum", "@trackAlbum": { "description": "Metadata label - album name" }, - "trackTrackNumber": "Track number", + "trackTrackNumber": "Número da faixa", "@trackTrackNumber": { "description": "Metadata label - track number" }, - "trackDiscNumber": "Disc number", + "trackDiscNumber": "Número do disco", "@trackDiscNumber": { "description": "Metadata label - disc number" }, - "trackDuration": "Duration", + "trackDuration": "Duração", "@trackDuration": { "description": "Metadata label - track length" }, - "trackAudioQuality": "Audio quality", + "trackAudioQuality": "Qualidade de Áudio", "@trackAudioQuality": { "description": "Metadata label - audio quality" }, - "trackReleaseDate": "Release date", + "trackReleaseDate": "Data de lançamento", "@trackReleaseDate": { "description": "Metadata label - release date" }, - "trackDownloaded": "Downloaded", + "trackDownloaded": "Baixado", "@trackDownloaded": { "description": "Metadata label - download date" }, @@ -2185,27 +2185,27 @@ "@extensionSettings": { "description": "Section header - extension settings" }, - "extensionRemoveButton": "Remove Extension", + "extensionRemoveButton": "Remover Extensão", "@extensionRemoveButton": { "description": "Button to uninstall extension" }, - "extensionUpdated": "Updated", + "extensionUpdated": "Atualizado", "@extensionUpdated": { "description": "Extension detail - last update" }, - "extensionMinAppVersion": "Min App Version", + "extensionMinAppVersion": "Versão Mínima do App", "@extensionMinAppVersion": { "description": "Extension detail - minimum app version" }, - "extensionCustomTrackMatching": "Custom Track Matching", + "extensionCustomTrackMatching": "Correspondência de Faixa Personalizada", "@extensionCustomTrackMatching": { "description": "Capability - custom track matching algorithm" }, - "extensionPostProcessing": "Post-Processing", + "extensionPostProcessing": "Pós-Processamento", "@extensionPostProcessing": { "description": "Capability - post-download processing" }, - "extensionHooksAvailable": "{count} hook(s) available", + "extensionHooksAvailable": "{count} gancho(s) disponíveis", "@extensionHooksAvailable": { "description": "Post-processing hooks count", "placeholders": { @@ -2214,7 +2214,7 @@ } } }, - "extensionPatternsCount": "{count} pattern(s)", + "extensionPatternsCount": "{count} padrão(ões)", "@extensionPatternsCount": { "description": "URL patterns count", "placeholders": { @@ -2223,7 +2223,7 @@ } } }, - "extensionStrategy": "Strategy: {strategy}", + "extensionStrategy": "Estratégia: {strategy}", "@extensionStrategy": { "description": "Track matching strategy name", "placeholders": { @@ -2232,75 +2232,75 @@ } } }, - "extensionsProviderPrioritySection": "Provider Priority", + "extensionsProviderPrioritySection": "Prioridade de Provedor", "@extensionsProviderPrioritySection": { "description": "Section header - provider priority" }, - "extensionsInstalledSection": "Installed Extensions", + "extensionsInstalledSection": "Extensões Instaladas", "@extensionsInstalledSection": { "description": "Section header - installed extensions" }, - "extensionsNoExtensions": "No extensions installed", + "extensionsNoExtensions": "Nenhuma extensão instalada", "@extensionsNoExtensions": { "description": "Empty state - no extensions" }, - "extensionsNoExtensionsSubtitle": "Install .spotiflac-ext files to add new providers", + "extensionsNoExtensionsSubtitle": "Instale arquivos .spotiflac-ext para adicionar novos provedores", "@extensionsNoExtensionsSubtitle": { "description": "Empty state subtitle" }, - "extensionsInstallButton": "Install Extension", + "extensionsInstallButton": "Instalar Extensão", "@extensionsInstallButton": { "description": "Button to install extension from file" }, - "extensionsInfoTip": "Extensions can add new metadata and download providers. Only install extensions from trusted sources.", + "extensionsInfoTip": "Extensões podem adicionar novos metadados e baixar provedores. Somente instale extensões a partir de fontes confiáveis.", "@extensionsInfoTip": { "description": "Security warning about extensions" }, - "extensionsInstalledSuccess": "Extension installed successfully", + "extensionsInstalledSuccess": "Extensão instalada com sucesso", "@extensionsInstalledSuccess": { "description": "Success message after install" }, - "extensionsDownloadPriority": "Download Priority", + "extensionsDownloadPriority": "Prioridade de Download", "@extensionsDownloadPriority": { "description": "Setting - download provider order" }, - "extensionsDownloadPrioritySubtitle": "Set download service order", + "extensionsDownloadPrioritySubtitle": "Definir ordem do serviço de download", "@extensionsDownloadPrioritySubtitle": { "description": "Subtitle for download priority" }, - "extensionsNoDownloadProvider": "No extensions with download provider", + "extensionsNoDownloadProvider": "Nenhuma extensão com provedor de download", "@extensionsNoDownloadProvider": { "description": "Empty state - no download providers" }, - "extensionsMetadataPriority": "Metadata Priority", + "extensionsMetadataPriority": "Prioridade de Metadados", "@extensionsMetadataPriority": { "description": "Setting - metadata provider order" }, - "extensionsMetadataPrioritySubtitle": "Set search & metadata source order", + "extensionsMetadataPrioritySubtitle": "Definir ordem de origem de pesquisa e metadados", "@extensionsMetadataPrioritySubtitle": { "description": "Subtitle for metadata priority" }, - "extensionsNoMetadataProvider": "No extensions with metadata provider", + "extensionsNoMetadataProvider": "Nenhuma extensão com provedor de metadados", "@extensionsNoMetadataProvider": { "description": "Empty state - no metadata providers" }, - "extensionsSearchProvider": "Search Provider", + "extensionsSearchProvider": "Provedor de Pesquisa", "@extensionsSearchProvider": { "description": "Setting - search provider selection" }, - "extensionsNoCustomSearch": "No extensions with custom search", + "extensionsNoCustomSearch": "Nenhuma extensão com pesquisa personalizada", "@extensionsNoCustomSearch": { "description": "Empty state - no search providers" }, - "extensionsSearchProviderDescription": "Choose which service to use for searching tracks", + "extensionsSearchProviderDescription": "Escolha qual serviço utilizar para pesquisar faixas", "@extensionsSearchProviderDescription": { "description": "Search provider setting description" }, - "extensionsCustomSearch": "Custom search", + "extensionsCustomSearch": "Busca personalizada", "@extensionsCustomSearch": { "description": "Label for custom search provider" }, - "extensionsErrorLoading": "Error loading extension", + "extensionsErrorLoading": "Erro ao carregar extensão", "@extensionsErrorLoading": { "description": "Error message when extension fails to load" }, @@ -2316,7 +2316,7 @@ "@qualityHiResFlac": { "description": "Quality option - high resolution FLAC" }, - "qualityHiResFlacSubtitle": "24-bit / up to 96kHz", + "qualityHiResFlacSubtitle": "24-bit / até 96kHz", "@qualityHiResFlacSubtitle": { "description": "Technical spec for hi-res" }, @@ -2324,55 +2324,55 @@ "@qualityHiResFlacMax": { "description": "Quality option - maximum resolution FLAC" }, - "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", + "qualityHiResFlacMaxSubtitle": "24-bit / até 192kHz", "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityNote": "Actual quality depends on track availability from the service", + "qualityNote": "A qualidade real depende da faixa que estiver disponível no serviço", "@qualityNote": { "description": "Note about quality availability" }, - "downloadAskBeforeDownload": "Ask Before Download", + "downloadAskBeforeDownload": "Perguntar qualidade antes de baixar", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" }, - "downloadDirectory": "Download Directory", + "downloadDirectory": "Pasta de Download", "@downloadDirectory": { "description": "Setting - download folder" }, - "downloadSeparateSinglesFolder": "Separate Singles Folder", + "downloadSeparateSinglesFolder": "Pasta de Singles Separada", "@downloadSeparateSinglesFolder": { "description": "Setting - separate folder for singles" }, - "downloadAlbumFolderStructure": "Album Folder Structure", + "downloadAlbumFolderStructure": "Estrutura da Pasta de Álbum", "@downloadAlbumFolderStructure": { "description": "Setting - album folder organization" }, - "downloadSaveFormat": "Save Format", + "downloadSaveFormat": "Formato para Salvar", "@downloadSaveFormat": { "description": "Setting - output file format" }, - "downloadSelectService": "Select Service", + "downloadSelectService": "Selecionar Serviço", "@downloadSelectService": { "description": "Dialog title - choose download service" }, - "downloadSelectQuality": "Select Quality", + "downloadSelectQuality": "Selecionar Qualidade", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" }, - "downloadFrom": "Download From", + "downloadFrom": "Baixar De", "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", + "downloadDefaultQualityLabel": "Qualidade Padrão", "@downloadDefaultQualityLabel": { "description": "Label - default quality setting" }, - "downloadBestAvailable": "Best available", + "downloadBestAvailable": "Melhor Disponível", "@downloadBestAvailable": { "description": "Quality option - highest available" }, - "folderNone": "None", + "folderNone": "Nenhum", "@folderNone": { "description": "Folder option - no organization" }, From da574f895c72eec91d685a04c63019bf97452d03 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 12:18:03 +0700 Subject: [PATCH 17/48] feat: v3.1.2 - MP3 option, dominant color headers, sticky titles, disc separation Added: - MP3 quality option with FLAC-to-MP3 conversion (320kbps) - Dominant color header backgrounds on detail screens - Spotify-style sticky title on scroll (album, playlist, artist screens) - Disc separation for multi-disc albums - Album grouping in recent downloads - 50% screen width cover art Changed: - Improved FFmpeg FLAC-to-MP3 conversion workflow - AppBar uses theme surface color when collapsed Fixed: - Empty catch blocks with proper comments - Russian plural forms (ICU syntax) Dependencies: - Added palette_generator ^0.3.3+4 --- CHANGELOG.md | 78 + lib/l10n/app_localizations.dart | 36 + lib/l10n/app_localizations_de.dart | 21 + lib/l10n/app_localizations_en.dart | 21 + lib/l10n/app_localizations_es.dart | 21 + lib/l10n/app_localizations_fr.dart | 21 + lib/l10n/app_localizations_hi.dart | 21 + lib/l10n/app_localizations_id.dart | 21 + lib/l10n/app_localizations_ja.dart | 21 + lib/l10n/app_localizations_ko.dart | 21 + lib/l10n/app_localizations_nl.dart | 21 + lib/l10n/app_localizations_pt.dart | 21 + lib/l10n/app_localizations_ru.dart | 41 +- lib/l10n/app_localizations_zh.dart | 21 + lib/l10n/arb/app_en.arb | 17 + lib/l10n/arb/app_id.arb | 2578 +++-------------- lib/l10n/arb/app_ru.arb | 20 +- lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 167 +- lib/providers/settings_provider.dart | 9 + lib/providers/track_provider.dart | 2 + lib/screens/album_screen.dart | 203 +- lib/screens/artist_screen.dart | 37 +- lib/screens/downloaded_album_screen.dart | 297 +- lib/screens/home_tab.dart | 35 +- lib/screens/playlist_screen.dart | 229 +- .../settings/download_settings_page.dart | 23 +- lib/screens/track_metadata_screen.dart | 177 +- lib/services/ffmpeg_service.dart | 156 +- lib/widgets/download_service_picker.dart | 25 +- pubspec.lock | 8 + pubspec.yaml | 3 +- 33 files changed, 1838 insertions(+), 2540 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57781320..9a958fca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # Changelog +## [3.1.2] - 2026-01-18 + +### Added + +- **MP3 Quality Option**: Optional MP3 download format with FLAC-to-MP3 conversion + - New "Enable MP3 Option" toggle in Settings > Download > Audio Quality + - When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options + - Available in both the quality picker dialog and default quality settings + - Works with all services (Tidal, Qobuz, Amazon) and extensions + +- **MP3 Metadata Embedding**: Full metadata support for MP3 files + - Cover art embedded using ID3v2 tags + - Synced lyrics embedded (fetched from lrclib.net) + - All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC + - Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3) + +- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds + - Extracts dominant color from cover art using `palette_generator` + - Creates a gradient from dominant color to theme surface color + - Smooth 500ms color transition animation + +- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed) + - More prominent album artwork display + - Larger shadow and rounded corners (20px radius) + - Higher resolution cover caching + +- **Spotify-style Sticky Title**: Title appears in AppBar when scrolling past the info card + - Smooth fade-in animation (200ms) when scrolling down + - Title hidden when header is expanded (shows in info card instead) + - AppBar uses theme color (surface) for clean, native look + - Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens + +- **Artist Name in Album Screen**: Album info card now displays artist name below album title + - Extracted from first track's artist metadata + - Styled with `onSurfaceVariant` color for visual hierarchy + +- **Disc Separation for Multi-Disc Albums**: Downloaded albums with multiple discs now display tracks grouped by disc + - Visual disc separator header showing "Disc 1", "Disc 2", etc. + - Tracks sorted by disc number first, then by track number + - Single-disc albums display normally without separators + - Fixes confusion when albums have duplicate track numbers across discs + +- **Album Grouping in Recents**: Downloads now show as albums instead of individual tracks in the Recent section + - Prevents flooding the recents list when downloading full albums + - Groups tracks by album name and artist + - Tapping navigates directly to the downloaded album screen + - Shows the most recent download time for each album + +### Changed + +- **FFmpeg FLAC-to-MP3 Conversion**: Improved conversion process + - MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder) + - Original FLAC file automatically deleted after successful conversion + - New `embedMetadataToMp3()` method for MP3-specific tag embedding + +- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed + - Dark theme: Black background with white text + - Light theme: White background with black text + - Matches Spotify's behavior for better readability + +### Fixed + +- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks + - `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored + - `track_provider.dart`: Added comments explaining why availability check errors are silently ignored + - `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures + +- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization + - Removed redundant `=1` clauses that were overriding `one` plural category + - Affected 10 plural strings including track counts and delete confirmations + - Plurals now correctly handle Russian grammar (1 трек, 2 трека, 5 треков) + +### Dependencies + +- Added `palette_generator: ^0.3.3+4` for cover art color extraction + +--- + ## [3.1.1] - 2026-01-17 ### Added diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c3781e32..07d59020 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3252,6 +3252,36 @@ abstract class AppLocalizations { /// **'24-bit / up to 192kHz'** String get qualityHiResFlacMaxSubtitle; + /// Quality option - MP3 lossy format + /// + /// In en, this message translates to: + /// **'MP3'** + String get qualityMp3; + + /// Technical spec for MP3 + /// + /// In en, this message translates to: + /// **'320kbps (converted from FLAC)'** + String get qualityMp3Subtitle; + + /// Setting - enable MP3 quality option + /// + /// In en, this message translates to: + /// **'Enable MP3 Option'** + String get enableMp3Option; + + /// Subtitle when MP3 is enabled + /// + /// In en, this message translates to: + /// **'MP3 quality option is available'** + String get enableMp3OptionSubtitleOn; + + /// Subtitle when MP3 is disabled + /// + /// In en, this message translates to: + /// **'Downloads FLAC then converts to 320kbps MP3'** + String get enableMp3OptionSubtitleOff; + /// Note about quality availability /// /// In en, this message translates to: @@ -3588,6 +3618,12 @@ abstract class AppLocalizations { /// **'Select tracks to delete'** String get downloadedAlbumSelectToDelete; + /// Header for disc separator in multi-disc albums + /// + /// In en, this message translates to: + /// **'Disc {discNumber}'** + String downloadedAlbumDiscHeader(int discNumber); + /// Extension capability - utility functions /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b4a9ff7e..b2175eb8 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1792,6 +1792,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1983,6 +1999,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3076e225..141f8d15 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1782,6 +1782,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1973,6 +1989,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index a5adb890..77660a73 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1782,6 +1782,22 @@ class AppLocalizationsEs extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1973,6 +1989,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 44995932..7fe07412 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1782,6 +1782,22 @@ class AppLocalizationsFr extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1973,6 +1989,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 7ada4ec3..bfe0e9cf 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1782,6 +1782,22 @@ class AppLocalizationsHi extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1973,6 +1989,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 85249455..4be2cad8 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1794,6 +1794,22 @@ class AppLocalizationsId extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (konversi dari FLAC)'; + + @override + String get enableMp3Option => 'Aktifkan Opsi MP3'; + + @override + String get enableMp3OptionSubtitleOn => 'Opsi kualitas MP3 tersedia'; + + @override + String get enableMp3OptionSubtitleOff => + 'Unduh FLAC lalu konversi ke MP3 320kbps'; + @override String get qualityNote => 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; @@ -1986,6 +2002,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Fungsi Utilitas'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 1ff5c936..a61062ba 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1782,6 +1782,22 @@ class AppLocalizationsJa extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1973,6 +1989,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 372a765f..ef5b02cc 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1782,6 +1782,22 @@ class AppLocalizationsKo extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1973,6 +1989,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index f3fb6361..e5972c08 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1782,6 +1782,22 @@ class AppLocalizationsNl extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1973,6 +1989,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 8e985b89..ed340a24 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1782,6 +1782,22 @@ class AppLocalizationsPt extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1973,6 +1989,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f431aa0e..eab39f44 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -74,9 +74,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: '$count треков', - one: '1 трек', many: '$count треков', few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @@ -87,9 +87,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: '$count альбомов', - one: '1 альбом', many: '$count альбомов', few: '$count альбома', + one: '$count альбом', ); return '$_temp0'; } @@ -489,9 +489,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: '$count треков', - one: '1 трек', many: '$count треков', few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @@ -523,9 +523,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: '$count релизов', - one: '1 релиз', many: '$count релизов', few: '$count релиза', + one: '$count релиз', ); return '$_temp0'; } @@ -901,9 +901,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.'; } @@ -946,9 +946,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Удалено $count $_temp0'; } @@ -1095,9 +1095,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Удалить $count $_temp0'; } @@ -1510,9 +1510,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: '$count треков', - one: '1 трек', many: '$count треков', few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @@ -1820,6 +1820,22 @@ class AppLocalizationsRu extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Фактическое качество зависит от доступности треков в сервисе'; @@ -1976,9 +1992,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.'; } @@ -2008,9 +2024,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Удалить $count $_temp0'; } @@ -2018,6 +2034,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Выберите треки для удаления'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Функции утилиты'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 83bbd344..e949682c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1782,6 +1782,22 @@ class AppLocalizationsZh extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1973,6 +1989,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + @override String get utilityFunctions => 'Utility Functions'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 98366459..5fa657bc 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1320,6 +1320,16 @@ "@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"}, "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", "@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"}, + "qualityMp3": "MP3", + "@qualityMp3": {"description": "Quality option - MP3 lossy format"}, + "qualityMp3Subtitle": "320kbps (converted from FLAC)", + "@qualityMp3Subtitle": {"description": "Technical spec for MP3"}, + "enableMp3Option": "Enable MP3 Option", + "@enableMp3Option": {"description": "Setting - enable MP3 quality option"}, + "enableMp3OptionSubtitleOn": "MP3 quality option is available", + "@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"}, + "enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3", + "@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"}, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": {"description": "Note about quality availability"}, @@ -1459,6 +1469,13 @@ }, "downloadedAlbumSelectToDelete": "Select tracks to delete", "@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"}, + "downloadedAlbumDiscHeader": "Disc {discNumber}", + "@downloadedAlbumDiscHeader": { + "description": "Header for disc separator in multi-disc albums", + "placeholders": { + "discNumber": {"type": "int", "example": "1"} + } + }, "utilityFunctions": "Utility Functions", "@utilityFunctions": {"description": "Extension capability - utility functions"}, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 06af2546..5c97f0de 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,2615 +1,687 @@ { "@@locale": "id", "@@last_modified": "2026-01-16", + "appName": "SpotiFLAC", - "@appName": { - "description": "App name - DO NOT TRANSLATE" - }, "appDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, + "navHome": "Beranda", - "@navHome": { - "description": "Bottom navigation - Home tab" - }, "navHistory": "Riwayat", - "@navHistory": { - "description": "Bottom navigation - History tab" - }, "navSettings": "Pengaturan", - "@navSettings": { - "description": "Bottom navigation - Settings tab" - }, "navStore": "Toko", - "@navStore": { - "description": "Bottom navigation - Extension store tab" - }, + "homeTitle": "Beranda", - "@homeTitle": { - "description": "Home screen title" - }, "homeSearchHint": "Tempel URL Spotify atau cari...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, "homeSearchHintExtension": "Cari dengan {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama", - "@homeSubtitle": { - "description": "Subtitle shown below search box" - }, "homeSupports": "Mendukung: URL Track, Album, Playlist, Artis", - "@homeSupports": { - "description": "Info text about supported URL types" - }, "homeRecent": "Terbaru", - "@homeRecent": { - "description": "Section header for recent searches" - }, + "historyTitle": "Riwayat", - "@historyTitle": { - "description": "History screen title" - }, "historyDownloading": "Mengunduh ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, "historyDownloaded": "Terunduh", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Semua", - "@historyFilterAll": { - "description": "Filter chip - show all items" - }, "historyFilterAlbums": "Album", - "@historyFilterAlbums": { - "description": "Filter chip - show albums only" - }, "historyFilterSingles": "Single", - "@historyFilterSingles": { - "description": "Filter chip - show singles only" - }, "historyTracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} album}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, "historyNoDownloads": "Tidak ada riwayat unduhan", - "@historyNoDownloads": { - "description": "Empty state title" - }, "historyNoDownloadsSubtitle": "Lagu yang diunduh akan muncul di sini", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, "historyNoAlbums": "Tidak ada unduhan album", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, "historyNoAlbumsSubtitle": "Unduh beberapa lagu dari album untuk melihatnya di sini", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, "historyNoSingles": "Tidak ada unduhan single", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, "historyNoSinglesSubtitle": "Unduhan lagu satuan akan muncul di sini", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, + "settingsTitle": "Pengaturan", - "@settingsTitle": { - "description": "Settings screen title" - }, "settingsDownload": "Unduhan", - "@settingsDownload": { - "description": "Settings section - download options" - }, "settingsAppearance": "Tampilan", - "@settingsAppearance": { - "description": "Settings section - visual customization" - }, "settingsOptions": "Opsi", - "@settingsOptions": { - "description": "Settings section - app options" - }, "settingsExtensions": "Ekstensi", - "@settingsExtensions": { - "description": "Settings section - extension management" - }, "settingsAbout": "Tentang", - "@settingsAbout": { - "description": "Settings section - app info" - }, + "downloadTitle": "Unduhan", - "@downloadTitle": { - "description": "Download settings page title" - }, "downloadLocation": "Lokasi Unduhan", - "@downloadLocation": { - "description": "Setting for download folder" - }, "downloadLocationSubtitle": "Pilih tempat menyimpan file", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, "downloadLocationDefault": "Lokasi default", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, "downloadDefaultService": "Layanan Default", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, "downloadDefaultServiceSubtitle": "Layanan yang digunakan untuk unduhan", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, "downloadDefaultQuality": "Kualitas Default", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, "downloadAskQuality": "Tanya Kualitas Sebelum Unduh", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Tampilkan pemilih kualitas untuk setiap unduhan", - "@downloadAskQualitySubtitle": { - "description": "Subtitle for ask quality toggle" - }, "downloadFilenameFormat": "Format Nama File", - "@downloadFilenameFormat": { - "description": "Setting for output filename pattern" - }, "downloadFolderOrganization": "Organisasi Folder", - "@downloadFolderOrganization": { - "description": "Setting for folder structure" - }, "downloadSeparateSingles": "Pisahkan Single", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, "downloadSeparateSinglesSubtitle": "Letakkan lagu satuan di folder terpisah", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, + "qualityBest": "Terbaik", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, + "appearanceTitle": "Tampilan", - "@appearanceTitle": { - "description": "Appearance settings page title" - }, "appearanceTheme": "Tema", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Sistem", - "@appearanceThemeSystem": { - "description": "Follow system theme" - }, "appearanceThemeLight": "Terang", - "@appearanceThemeLight": { - "description": "Light theme" - }, "appearanceThemeDark": "Gelap", - "@appearanceThemeDark": { - "description": "Dark theme" - }, "appearanceDynamicColor": "Warna Dinamis", - "@appearanceDynamicColor": { - "description": "Material You dynamic colors" - }, "appearanceDynamicColorSubtitle": "Gunakan warna dari wallpaper Anda", - "@appearanceDynamicColorSubtitle": { - "description": "Subtitle for dynamic color" - }, "appearanceAccentColor": "Warna Aksen", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Tampilan Riwayat", - "@appearanceHistoryView": { - "description": "Layout style for history" - }, "appearanceHistoryViewList": "Daftar", - "@appearanceHistoryViewList": { - "description": "List layout option" - }, "appearanceHistoryViewGrid": "Grid", - "@appearanceHistoryViewGrid": { - "description": "Grid layout option" - }, + "optionsTitle": "Opsi", - "@optionsTitle": { - "description": "Options settings page title" - }, "optionsSearchSource": "Sumber Pencarian", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Provider Utama", - "@optionsPrimaryProvider": { - "description": "Main search provider setting" - }, "optionsPrimaryProviderSubtitle": "Layanan yang digunakan saat mencari berdasarkan nama lagu.", - "@optionsPrimaryProviderSubtitle": { - "description": "Subtitle for primary provider" - }, "optionsUsingExtension": "Menggunakan ekstensi: {extensionName}", - "@optionsUsingExtension": { - "description": "Shows active extension name", - "placeholders": { - "extensionName": { - "type": "String" - } - } - }, "optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi", - "@optionsSwitchBack": { - "description": "Hint to switch back to built-in providers" - }, "optionsAutoFallback": "Auto Fallback", - "@optionsAutoFallback": { - "description": "Auto-retry with other services" - }, "optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal", - "@optionsAutoFallbackSubtitle": { - "description": "Subtitle for auto fallback" - }, "optionsUseExtensionProviders": "Gunakan Provider Ekstensi", - "@optionsUseExtensionProviders": { - "description": "Enable extension download providers" - }, "optionsUseExtensionProvidersOn": "Ekstensi akan dicoba terlebih dahulu", - "@optionsUseExtensionProvidersOn": { - "description": "Status when extension providers enabled" - }, "optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan", - "@optionsUseExtensionProvidersOff": { - "description": "Status when extension providers disabled" - }, "optionsEmbedLyrics": "Sematkan Lirik", - "@optionsEmbedLyrics": { - "description": "Embed lyrics in audio files" - }, "optionsEmbedLyricsSubtitle": "Sematkan lirik sinkron ke file FLAC", - "@optionsEmbedLyricsSubtitle": { - "description": "Subtitle for embed lyrics" - }, "optionsMaxQualityCover": "Cover Kualitas Maksimal", - "@optionsMaxQualityCover": { - "description": "Download highest quality album art" - }, "optionsMaxQualityCoverSubtitle": "Unduh cover art resolusi tertinggi", - "@optionsMaxQualityCoverSubtitle": { - "description": "Subtitle for max quality cover" - }, "optionsConcurrentDownloads": "Unduhan Bersamaan", - "@optionsConcurrentDownloads": { - "description": "Number of parallel downloads" - }, "optionsConcurrentSequential": "Berurutan (1 per waktu)", - "@optionsConcurrentSequential": { - "description": "Download one at a time" - }, "optionsConcurrentParallel": "{count} unduhan paralel", - "@optionsConcurrentParallel": { - "description": "Multiple parallel downloads", - "placeholders": { - "count": { - "type": "int" - } - } - }, "optionsConcurrentWarning": "Unduhan paralel dapat memicu pembatasan rate", - "@optionsConcurrentWarning": { - "description": "Warning about rate limits" - }, "optionsExtensionStore": "Toko Ekstensi", - "@optionsExtensionStore": { - "description": "Show/hide store tab" - }, "optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi", - "@optionsExtensionStoreSubtitle": { - "description": "Subtitle for extension store toggle" - }, "optionsCheckUpdates": "Periksa Pembaruan", - "@optionsCheckUpdates": { - "description": "Auto update check toggle" - }, "optionsCheckUpdatesSubtitle": "Beritahu saat versi baru tersedia", - "@optionsCheckUpdatesSubtitle": { - "description": "Subtitle for update check" - }, "optionsUpdateChannel": "Saluran Pembaruan", - "@optionsUpdateChannel": { - "description": "Stable vs preview releases" - }, "optionsUpdateChannelStable": "Hanya rilis stabil", - "@optionsUpdateChannelStable": { - "description": "Only stable updates" - }, "optionsUpdateChannelPreview": "Dapatkan rilis preview", - "@optionsUpdateChannelPreview": { - "description": "Include beta/preview updates" - }, "optionsUpdateChannelWarning": "Preview mungkin mengandung bug atau fitur belum lengkap", - "@optionsUpdateChannelWarning": { - "description": "Warning about preview channel" - }, "optionsClearHistory": "Hapus Riwayat Unduhan", - "@optionsClearHistory": { - "description": "Delete all download history" - }, "optionsClearHistorySubtitle": "Hapus semua lagu dari riwayat", - "@optionsClearHistorySubtitle": { - "description": "Subtitle for clear history" - }, "optionsDetailedLogging": "Log Detail", - "@optionsDetailedLogging": { - "description": "Enable verbose logs for debugging" - }, "optionsDetailedLoggingOn": "Log detail sedang direkam", - "@optionsDetailedLoggingOn": { - "description": "Status when logging enabled" - }, "optionsDetailedLoggingOff": "Aktifkan untuk laporan bug", - "@optionsDetailedLoggingOff": { - "description": "Status when logging disabled" - }, "optionsSpotifyCredentials": "Kredensial Spotify", - "@optionsSpotifyCredentials": { - "description": "Spotify API credentials setting" - }, "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", - "@optionsSpotifyCredentialsConfigured": { - "description": "Shows configured client ID preview", - "placeholders": { - "clientId": { - "type": "String" - } - } - }, "optionsSpotifyCredentialsRequired": "Diperlukan - ketuk untuk mengatur", - "@optionsSpotifyCredentialsRequired": { - "description": "Prompt to set up credentials" - }, "optionsSpotifyWarning": "Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com", - "@optionsSpotifyWarning": { - "description": "Info about Spotify API requirement" - }, + "extensionsTitle": "Ekstensi", - "@extensionsTitle": { - "description": "Extensions page title" - }, "extensionsInstalled": "Ekstensi Terpasang", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, "extensionsNone": "Tidak ada ekstensi terpasang", - "@extensionsNone": { - "description": "Empty state title" - }, "extensionsNoneSubtitle": "Pasang ekstensi dari tab Toko", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, "extensionsEnabled": "Aktif", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Nonaktif", - "@extensionsDisabled": { - "description": "Extension status - inactive" - }, "extensionsVersion": "Versi {version}", - "@extensionsVersion": { - "description": "Extension version display", - "placeholders": { - "version": { - "type": "String" - } - } - }, "extensionsAuthor": "oleh {author}", - "@extensionsAuthor": { - "description": "Extension author credit", - "placeholders": { - "author": { - "type": "String" - } - } - }, "extensionsUninstall": "Copot", - "@extensionsUninstall": { - "description": "Uninstall extension button" - }, "extensionsSetAsSearch": "Jadikan Provider Pencarian", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, + "storeTitle": "Toko Ekstensi", - "@storeTitle": { - "description": "Store screen title" - }, "storeSearch": "Cari ekstensi...", - "@storeSearch": { - "description": "Store search placeholder" - }, "storeInstall": "Pasang", - "@storeInstall": { - "description": "Install extension button" - }, "storeInstalled": "Terpasang", - "@storeInstalled": { - "description": "Already installed badge" - }, "storeUpdate": "Perbarui", - "@storeUpdate": { - "description": "Update available button" - }, + "aboutTitle": "Tentang", - "@aboutTitle": { - "description": "About page title" - }, "aboutContributors": "Kontributor", - "@aboutContributors": { - "description": "Section for contributors" - }, "aboutMobileDeveloper": "Pengembang versi mobile", - "@aboutMobileDeveloper": { - "description": "Role description for mobile dev" - }, - "aboutOriginalCreator": "Pembuat SpotiFLAC asli", - "@aboutOriginalCreator": { - "description": "Role description for original creator" - }, - "aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!", - "@aboutLogoArtist": { - "description": "Role description for logo artist" - }, + "aboutOriginalCreator": "Pencipta SpotiFLAC asli", + "aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kami yang indah!", "aboutSpecialThanks": "Terima Kasih Khusus", - "@aboutSpecialThanks": { - "description": "Section for special thanks" - }, "aboutLinks": "Tautan", - "@aboutLinks": { - "description": "Section for external links" - }, "aboutMobileSource": "Kode sumber mobile", - "@aboutMobileSource": { - "description": "Link to mobile GitHub repo" - }, "aboutPCSource": "Kode sumber PC", - "@aboutPCSource": { - "description": "Link to PC GitHub repo" - }, "aboutReportIssue": "Laporkan masalah", - "@aboutReportIssue": { - "description": "Link to report bugs" - }, "aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui", - "@aboutReportIssueSubtitle": { - "description": "Subtitle for report issue" - }, "aboutFeatureRequest": "Permintaan fitur", - "@aboutFeatureRequest": { - "description": "Link to suggest features" - }, "aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi", - "@aboutFeatureRequestSubtitle": { - "description": "Subtitle for feature request" - }, "aboutSupport": "Dukungan", - "@aboutSupport": { - "description": "Section for support/donation links" - }, - "aboutBuyMeCoffee": "Belikan saya kopi", - "@aboutBuyMeCoffee": { - "description": "Donation link" - }, + "aboutBuyMeCoffee": "Traktir saya kopi", "aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi", - "@aboutBuyMeCoffeeSubtitle": { - "description": "Subtitle for donation" - }, "aboutApp": "Aplikasi", - "@aboutApp": { - "description": "Section for app info" - }, "aboutVersion": "Versi", - "@aboutVersion": { - "description": "Version info label" - }, - "aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!", - "@aboutBinimumDesc": { - "description": "Credit description for binimum" - }, - "aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!", - "@aboutSachinsenalDesc": { - "description": "Credit description for sachinsenal0x64" - }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, - "aboutDabMusic": "DAB Music", - "@aboutDabMusic": { - "description": "Name of Qobuz API service - DO NOT TRANSLATE" - }, - "aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!", - "@aboutDabMusicDesc": { - "description": "Credit for DAB Music API" - }, - "aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", - "@aboutAppDescription": { - "description": "App description in header card" - }, + "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, "albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "albumDownloadAll": "Unduh Semua", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, "albumDownloadRemaining": "Unduh Sisanya", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, + "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, "artistTitle": "Artis", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Album", - "@artistAlbums": { - "description": "Section header for artist albums" - }, "artistSingles": "Single & EP", - "@artistSingles": { - "description": "Section header for singles/EPs" - }, - "artistCompilations": "Kompilasi", - "@artistCompilations": { - "description": "Section header for compilations" - }, - "artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "artistPopular": "Populer", - "@artistPopular": { - "description": "Section header for popular/top tracks" - }, - "artistMonthlyListeners": "{count} pendengar bulanan", - "@artistMonthlyListeners": { - "description": "Monthly listener count display", - "placeholders": { - "count": { - "type": "String", - "description": "Formatted listener count" - } - } - }, + "trackMetadataTitle": "Info Lagu", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, "trackMetadataArtist": "Artis", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, "trackMetadataDuration": "Durasi", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, "trackMetadataQuality": "Kualitas", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, "trackMetadataPath": "Lokasi File", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, "trackMetadataDownloadedAt": "Diunduh", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Layanan", - "@trackMetadataService": { - "description": "Metadata field - download service used" - }, "trackMetadataPlay": "Putar", - "@trackMetadataPlay": { - "description": "Action button - play track" - }, "trackMetadataShare": "Bagikan", - "@trackMetadataShare": { - "description": "Action button - share track" - }, "trackMetadataDelete": "Hapus", - "@trackMetadataDelete": { - "description": "Action button - delete track" - }, "trackMetadataRedownload": "Unduh ulang", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, "trackMetadataOpenFolder": "Buka Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, + "setupTitle": "Selamat Datang di SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, "setupSubtitle": "Mari mulai pengaturan", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, "setupStoragePermission": "Izin Penyimpanan", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, "setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, "setupStoragePermissionGranted": "Izin diberikan", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, "setupStoragePermissionDenied": "Izin ditolak", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Berikan Izin", - "@setupGrantPermission": { - "description": "Button to request permission" - }, "setupDownloadLocation": "Lokasi Unduhan", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, "setupChooseFolder": "Pilih Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, "setupContinue": "Lanjutkan", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Lewati untuk sekarang", - "@setupSkip": { - "description": "Skip current step button" - }, - "setupStorageAccessRequired": "Akses Penyimpanan Diperlukan", - "@setupStorageAccessRequired": { - "description": "Title when storage access needed" - }, - "setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, - "setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.", - "@setupStorageAccessMessageAndroid11": { - "description": "Android 11+ specific explanation" - }, - "setupOpenSettings": "Buka Pengaturan", - "@setupOpenSettings": { - "description": "Button to open system settings" - }, - "setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.", - "@setupPermissionDeniedMessage": { - "description": "Error when permission denied" - }, - "setupPermissionRequired": "Izin {permissionType} Diperlukan", - "@setupPermissionRequired": { - "description": "Generic permission required title", - "placeholders": { - "permissionType": { - "type": "String", - "description": "Type of permission (Storage/Notification)" - } - } - }, - "setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.", - "@setupPermissionRequiredMessage": { - "description": "Generic permission required message", - "placeholders": { - "permissionType": { - "type": "String" - } - } - }, - "setupSelectDownloadFolder": "Pilih Folder Unduhan", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, - "setupUseDefaultFolder": "Gunakan Folder Default?", - "@setupUseDefaultFolder": { - "description": "Dialog title for default folder" - }, - "setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?", - "@setupNoFolderSelected": { - "description": "Prompt when no folder selected" - }, - "setupUseDefault": "Gunakan Default", - "@setupUseDefault": { - "description": "Button to use default folder" - }, - "setupDownloadLocationTitle": "Lokasi Unduhan", - "@setupDownloadLocationTitle": { - "description": "Download location dialog title" - }, - "setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.", - "@setupDownloadLocationIosMessage": { - "description": "iOS-specific folder info" - }, - "setupAppDocumentsFolder": "Folder Documents Aplikasi", - "@setupAppDocumentsFolder": { - "description": "iOS documents folder option" - }, - "setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files", - "@setupAppDocumentsFolderSubtitle": { - "description": "Subtitle for documents folder" - }, - "setupChooseFromFiles": "Pilih dari Files", - "@setupChooseFromFiles": { - "description": "iOS file picker option" - }, - "setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya", - "@setupChooseFromFilesSubtitle": { - "description": "Subtitle for file picker" - }, - "setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.", - "@setupIosEmptyFolderWarning": { - "description": "iOS folder selection warning" - }, - "setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC", - "@setupDownloadInFlac": { - "description": "App tagline in setup" - }, - "setupStepStorage": "Penyimpanan", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notifikasi", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Izin", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, - "setupStorageGranted": "Izin Penyimpanan Diberikan!", - "@setupStorageGranted": { - "description": "Success message for storage permission" - }, - "setupStorageRequired": "Izin Penyimpanan Diperlukan", - "@setupStorageRequired": { - "description": "Title when storage permission needed" - }, - "setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.", - "@setupStorageDescription": { - "description": "Explanation for storage permission" - }, - "setupNotificationGranted": "Izin Notifikasi Diberikan!", - "@setupNotificationGranted": { - "description": "Success message for notification permission" - }, - "setupNotificationEnable": "Aktifkan Notifikasi", - "@setupNotificationEnable": { - "description": "Button to enable notifications" - }, - "setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Folder Unduhan Dipilih!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, - "setupFolderChoose": "Pilih Folder Unduhan", - "@setupFolderChoose": { - "description": "Button to choose folder" - }, - "setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.", - "@setupFolderDescription": { - "description": "Explanation for folder selection" - }, - "setupChangeFolder": "Ubah Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, - "setupSelectFolder": "Pilih Folder", - "@setupSelectFolder": { - "description": "Button to select folder" - }, - "setupSpotifyApiOptional": "Spotify API (Opsional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Gunakan Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Masukkan Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Masukkan Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, - "setupEnableNotifications": "Aktifkan Notifikasi", - "@setupEnableNotifications": { - "description": "Button to enable notifications" - }, - "setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, - "setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.", - "@setupNotificationBackgroundDescription": { - "description": "Detailed notification explanation" - }, - "setupSkipForNow": "Lewati untuk sekarang", - "@setupSkipForNow": { - "description": "Skip button text" - }, - "setupBack": "Kembali", - "@setupBack": { - "description": "Back button text" - }, - "setupNext": "Lanjut", - "@setupNext": { - "description": "Next button text" - }, - "setupGetStarted": "Mulai", - "@setupGetStarted": { - "description": "Final setup button" - }, - "setupSkipAndStart": "Lewati & Mulai", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, - "setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.", - "@setupAllowAccessToManageFiles": { - "description": "Instruction for file access permission" - }, - "setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, + "dialogCancel": "Batal", - "@dialogCancel": { - "description": "Dialog button - cancel action" - }, "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Simpan", - "@dialogSave": { - "description": "Dialog button - save changes" - }, "dialogDelete": "Hapus", - "@dialogDelete": { - "description": "Dialog button - delete item" - }, "dialogRetry": "Coba Lagi", - "@dialogRetry": { - "description": "Dialog button - retry action" - }, "dialogClose": "Tutup", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, "dialogYes": "Ya", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, "dialogNo": "Tidak", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Hapus", - "@dialogClear": { - "description": "Dialog button - clear items" - }, "dialogConfirm": "Konfirmasi", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Selesai", - "@dialogDone": { - "description": "Dialog button - action completed" - }, - "dialogImport": "Impor", - "@dialogImport": { - "description": "Dialog button - import data" - }, - "dialogDiscard": "Buang", - "@dialogDiscard": { - "description": "Dialog button - discard changes" - }, - "dialogRemove": "Hapus", - "@dialogRemove": { - "description": "Dialog button - remove item" - }, - "dialogUninstall": "Copot", - "@dialogUninstall": { - "description": "Dialog button - uninstall extension" - }, - "dialogDiscardChanges": "Buang Perubahan?", - "@dialogDiscardChanges": { - "description": "Dialog title - unsaved changes warning" - }, - "dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?", - "@dialogUnsavedChanges": { - "description": "Dialog message - unsaved changes" - }, - "dialogDownloadFailed": "Unduhan Gagal", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Lagu:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artis:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, - "dialogClearAll": "Hapus Semua", - "@dialogClearAll": { - "description": "Dialog title - clear all items" - }, - "dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Hapus dari perangkat?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, - "dialogRemoveExtension": "Hapus Ekstensi", - "@dialogRemoveExtension": { - "description": "Dialog title - uninstall extension" - }, - "dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.", - "@dialogRemoveExtensionMessage": { - "description": "Dialog message - uninstall confirmation" - }, - "dialogUninstallExtension": "Copot Ekstensi?", - "@dialogUninstallExtension": { - "description": "Dialog title - uninstall extension" - }, - "dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?", - "@dialogUninstallExtensionMessage": { - "description": "Dialog message - uninstall specific extension", - "placeholders": { - "extensionName": { - "type": "String" - } - } - }, + "dialogClearHistoryTitle": "Hapus Riwayat", - "@dialogClearHistoryTitle": { - "description": "Dialog title - clear download history" - }, "dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.", - "@dialogClearHistoryMessage": { - "description": "Dialog message - clear history confirmation" - }, "dialogDeleteSelectedTitle": "Hapus yang Dipilih", - "@dialogDeleteSelectedTitle": { - "description": "Dialog title - delete selected items" - }, "dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.", - "@dialogDeleteSelectedMessage": { - "description": "Dialog message - delete selected tracks", - "placeholders": { - "count": { - "type": "int" - } - } - }, "dialogImportPlaylistTitle": "Impor Playlist", - "@dialogImportPlaylistTitle": { - "description": "Dialog title - import CSV playlist" - }, "dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?", - "@dialogImportPlaylistMessage": { - "description": "Dialog message - import playlist confirmation", - "placeholders": { - "count": { - "type": "int" - } - } - }, + "snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian", - "@snackbarAddedToQueue": { - "description": "Snackbar - track added to download queue", - "placeholders": { - "trackName": { - "type": "String" - } - } - }, "snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian", - "@snackbarAddedTracksToQueue": { - "description": "Snackbar - multiple tracks added to queue", - "placeholders": { - "count": { - "type": "int" - } - } - }, "snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh", - "@snackbarAlreadyDownloaded": { - "description": "Snackbar - track already exists", - "placeholders": { - "trackName": { - "type": "String" - } - } - }, "snackbarHistoryCleared": "Riwayat dihapus", - "@snackbarHistoryCleared": { - "description": "Snackbar - history deleted" - }, "snackbarCredentialsSaved": "Kredensial disimpan", - "@snackbarCredentialsSaved": { - "description": "Snackbar - Spotify credentials saved" - }, "snackbarCredentialsCleared": "Kredensial dihapus", - "@snackbarCredentialsCleared": { - "description": "Snackbar - Spotify credentials removed" - }, "snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}", - "@snackbarDeletedTracks": { - "description": "Snackbar - tracks deleted", - "placeholders": { - "count": { - "type": "int" - } - } - }, "snackbarCannotOpenFile": "Tidak dapat membuka file: {error}", - "@snackbarCannotOpenFile": { - "description": "Snackbar - file open error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarFillAllFields": "Harap isi semua field", - "@snackbarFillAllFields": { - "description": "Snackbar - validation error" - }, "snackbarViewQueue": "Lihat Antrian", - "@snackbarViewQueue": { - "description": "Snackbar action - view download queue" - }, - "snackbarFailedToLoad": "Gagal memuat: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, - "snackbarUrlCopied": "URL {platform} disalin ke clipboard", - "@snackbarUrlCopied": { - "description": "Snackbar - URL copied", - "placeholders": { - "platform": { - "type": "String", - "description": "Platform name (Spotify/Deezer)" - } - } - }, - "snackbarFileNotFound": "File tidak ditemukan", - "@snackbarFileNotFound": { - "description": "Snackbar - file doesn't exist" - }, - "snackbarSelectExtFile": "Harap pilih file .spotiflac-ext", - "@snackbarSelectExtFile": { - "description": "Snackbar - wrong file type selected" - }, - "snackbarProviderPrioritySaved": "Prioritas provider disimpan", - "@snackbarProviderPrioritySaved": { - "description": "Snackbar - provider order saved" - }, - "snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan", - "@snackbarMetadataProviderSaved": { - "description": "Snackbar - metadata provider order saved" - }, - "snackbarExtensionInstalled": "{extensionName} terpasang.", - "@snackbarExtensionInstalled": { - "description": "Snackbar - extension installed successfully", - "placeholders": { - "extensionName": { - "type": "String" - } - } - }, - "snackbarExtensionUpdated": "{extensionName} diperbarui.", - "@snackbarExtensionUpdated": { - "description": "Snackbar - extension updated successfully", - "placeholders": { - "extensionName": { - "type": "String" - } - } - }, - "snackbarFailedToInstall": "Gagal memasang ekstensi", - "@snackbarFailedToInstall": { - "description": "Snackbar - extension install error" - }, - "snackbarFailedToUpdate": "Gagal memperbarui ekstensi", - "@snackbarFailedToUpdate": { - "description": "Snackbar - extension update error" - }, + "errorRateLimited": "Dibatasi", - "@errorRateLimited": { - "description": "Error title - too many requests" - }, "errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.", - "@errorRateLimitedMessage": { - "description": "Error message - rate limit explanation" - }, "errorFailedToLoad": "Gagal memuat {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "Tidak ada lagu ditemukan", - "@errorNoTracksFound": { - "description": "Error - search returned no results" - }, "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", - "@errorMissingExtensionSource": { - "description": "Error - extension source not available", - "placeholders": { - "item": { - "type": "String" - } - } - }, + "statusQueued": "Mengantri", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, "statusDownloading": "Mengunduh", - "@statusDownloading": { - "description": "Download status - in progress" - }, "statusFinalizing": "Menyelesaikan", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, "statusCompleted": "Selesai", - "@statusCompleted": { - "description": "Download status - finished" - }, "statusFailed": "Gagal", - "@statusFailed": { - "description": "Download status - error occurred" - }, "statusSkipped": "Dilewati", - "@statusSkipped": { - "description": "Download status - already exists" - }, "statusPaused": "Dijeda", - "@statusPaused": { - "description": "Download status - paused" - }, + "actionPause": "Jeda", - "@actionPause": { - "description": "Action button - pause download" - }, "actionResume": "Lanjutkan", - "@actionResume": { - "description": "Action button - resume download" - }, "actionCancel": "Batal", - "@actionCancel": { - "description": "Action button - cancel operation" - }, "actionStop": "Hentikan", - "@actionStop": { - "description": "Action button - stop operation" - }, "actionSelect": "Pilih", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Pilih Semua", - "@actionSelectAll": { - "description": "Action button - select all items" - }, "actionDeselect": "Batal Pilih", - "@actionDeselect": { - "description": "Action button - deselect all" - }, "actionPaste": "Tempel", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, "actionImportCsv": "Impor CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Hapus Kredensial", - "@actionRemoveCredentials": { - "description": "Action button - delete Spotify credentials" - }, "actionSaveCredentials": "Simpan Kredensial", - "@actionSaveCredentials": { - "description": "Action button - save Spotify credentials" - }, + "selectionSelected": "{count} dipilih", - "@selectionSelected": { - "description": "Selection count indicator", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionAllSelected": "Semua lagu dipilih", - "@selectionAllSelected": { - "description": "Status - all items selected" - }, "selectionTapToSelect": "Ketuk lagu untuk memilih", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, "selectionDeleteTracks": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Pilih lagu untuk dihapus", - "@selectionSelectToDelete": { - "description": "Placeholder when nothing selected" - }, + "progressFetchingMetadata": "Mengambil metadata... {current}/{total}", - "@progressFetchingMetadata": { - "description": "Progress indicator - loading track info", - "placeholders": { - "current": { - "type": "int" - }, - "total": { - "type": "int" - } - } - }, "progressReadingCsv": "Membaca CSV...", - "@progressReadingCsv": { - "description": "Progress indicator - parsing CSV file" - }, + "searchSongs": "Lagu", - "@searchSongs": { - "description": "Search result category - songs" - }, "searchArtists": "Artis", - "@searchArtists": { - "description": "Search result category - artists" - }, "searchAlbums": "Album", - "@searchAlbums": { - "description": "Search result category - albums" - }, "searchPlaylists": "Playlist", - "@searchPlaylists": { - "description": "Search result category - playlists" - }, + "tooltipPlay": "Putar", - "@tooltipPlay": { - "description": "Tooltip - play button" - }, "tooltipCancel": "Batal", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, "tooltipStop": "Hentikan", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, "tooltipRetry": "Coba Lagi", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, "tooltipRemove": "Hapus", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, "tooltipClear": "Hapus", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, "tooltipPaste": "Tempel", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, + "filenameFormat": "Format Nama File", - "@filenameFormat": { - "description": "Setting title - filename pattern" - }, "filenameFormatPreview": "Pratinjau: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Placeholder yang tersedia:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, "folderOrganization": "Organisasi Folder", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, - "folderOrganizationNone": "Tidak ada", - "@folderOrganizationNone": { - "description": "Folder option - flat structure" - }, + "folderOrganizationNone": "Tanpa organisasi", "folderOrganizationByArtist": "Berdasarkan Artis", - "@folderOrganizationByArtist": { - "description": "Folder option - artist folders" - }, "folderOrganizationByAlbum": "Berdasarkan Album", - "@folderOrganizationByAlbum": { - "description": "Folder option - album folders" - }, - "folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album", - "@folderOrganizationByArtistAlbum": { - "description": "Folder option - nested folders" - }, - "folderOrganizationDescription": "Atur file yang diunduh ke dalam folder", - "@folderOrganizationDescription": { - "description": "Folder organization sheet description" - }, - "folderOrganizationNoneSubtitle": "Semua file di folder unduhan", - "@folderOrganizationNoneSubtitle": { - "description": "Subtitle for no organization option" - }, - "folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis", - "@folderOrganizationByArtistSubtitle": { - "description": "Subtitle for artist folder option" - }, - "folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album", - "@folderOrganizationByAlbumSubtitle": { - "description": "Subtitle for album folder option" - }, - "folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album", - "@folderOrganizationByArtistAlbumSubtitle": { - "description": "Subtitle for nested folder option" - }, + "folderOrganizationByArtistAlbum": "Artis/Album", + "updateAvailable": "Pembaruan Tersedia", - "@updateAvailable": { - "description": "Update dialog title" - }, "updateNewVersion": "Versi {version} tersedia", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, "updateDownload": "Unduh", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Nanti", - "@updateLater": { - "description": "Update button - dismiss" - }, "updateChangelog": "Log Perubahan", - "@updateChangelog": { - "description": "Link to changelog" - }, - "updateStartingDownload": "Memulai unduhan...", - "@updateStartingDownload": { - "description": "Update status - initializing" - }, - "updateDownloadFailed": "Unduhan gagal", - "@updateDownloadFailed": { - "description": "Update error title" - }, - "updateFailedMessage": "Gagal mengunduh pembaruan", - "@updateFailedMessage": { - "description": "Update error message" - }, - "updateNewVersionReady": "Versi baru sudah siap", - "@updateNewVersionReady": { - "description": "Update subtitle" - }, - "updateCurrent": "Saat ini", - "@updateCurrent": { - "description": "Label for current version" - }, - "updateNew": "Baru", - "@updateNew": { - "description": "Label for new version" - }, - "updateDownloading": "Mengunduh...", - "@updateDownloading": { - "description": "Update status - downloading" - }, - "updateWhatsNew": "Yang Baru", - "@updateWhatsNew": { - "description": "Changelog section title" - }, - "updateDownloadInstall": "Unduh & Pasang", - "@updateDownloadInstall": { - "description": "Update button - download and install" - }, - "updateDontRemind": "Jangan ingatkan", - "@updateDontRemind": { - "description": "Update button - skip this version" - }, + "providerPriority": "Prioritas Provider", - "@providerPriority": { - "description": "Setting title - download provider order" - }, "providerPrioritySubtitle": "Seret untuk mengatur ulang provider unduhan", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, - "providerPriorityTitle": "Prioritas Provider", - "@providerPriorityTitle": { - "description": "Provider priority page title" - }, - "providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.", - "@providerPriorityDescription": { - "description": "Provider priority page description" - }, - "providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.", - "@providerPriorityInfo": { - "description": "Info tip about fallback behavior" - }, - "providerBuiltIn": "Bawaan", - "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" - }, - "providerExtension": "Ekstensi", - "@providerExtension": { - "description": "Label for extension-provided providers" - }, "metadataProviderPriority": "Prioritas Provider Metadata", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, "metadataProviderPrioritySubtitle": "Urutan yang digunakan saat mengambil metadata lagu", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, - "metadataProviderPriorityTitle": "Prioritas Metadata", - "@metadataProviderPriorityTitle": { - "description": "Metadata priority page title" - }, - "metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.", - "@metadataProviderPriorityDescription": { - "description": "Metadata priority page description" - }, - "metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.", - "@metadataProviderPriorityInfo": { - "description": "Info tip about rate limits" - }, - "metadataNoRateLimits": "Tidak ada batas rate", - "@metadataNoRateLimits": { - "description": "Deezer provider description" - }, - "metadataMayRateLimit": "Mungkin dibatasi rate", - "@metadataMayRateLimit": { - "description": "Spotify provider description" - }, + "logTitle": "Log", - "@logTitle": { - "description": "Logs screen title" - }, "logCopy": "Salin Log", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, "logClear": "Hapus Log", - "@logClear": { - "description": "Action - delete all logs" - }, "logShare": "Bagikan Log", - "@logShare": { - "description": "Action - share logs file" - }, "logEmpty": "Belum ada log", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Log disalin ke clipboard", - "@logCopied": { - "description": "Snackbar - logs copied" - }, - "logSearchHint": "Cari log...", - "@logSearchHint": { - "description": "Log search placeholder" - }, - "logFilterLevel": "Level", - "@logFilterLevel": { - "description": "Filter by log level" - }, - "logFilterSection": "Filter", - "@logFilterSection": { - "description": "Filter section title" - }, - "logShareLogs": "Bagikan log", - "@logShareLogs": { - "description": "Share button tooltip" - }, - "logClearLogs": "Hapus log", - "@logClearLogs": { - "description": "Clear button tooltip" - }, - "logClearLogsTitle": "Hapus Log", - "@logClearLogsTitle": { - "description": "Clear logs dialog title" - }, - "logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?", - "@logClearLogsMessage": { - "description": "Clear logs confirmation message" - }, - "logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "DIBATASI", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ERROR JARINGAN", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "LAGU TIDAK DITEMUKAN", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, - "logFilterBySeverity": "Filter log berdasarkan tingkat keparahan", - "@logFilterBySeverity": { - "description": "Filter dialog title" - }, - "logNoLogsYet": "Belum ada log", - "@logNoLogsYet": { - "description": "Empty state title" - }, - "logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi", - "@logNoLogsYetSubtitle": { - "description": "Empty state subtitle" - }, - "logIssueSummary": "Ringkasan Masalah", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Terlalu banyak permintaan ke layanan", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Masalah koneksi terdeteksi", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Periksa koneksi internet Anda", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total error: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Terpengaruh: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, - "logEntriesFiltered": "Entri ({count} difilter)", - "@logEntriesFiltered": { - "description": "Log count with filter active", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logEntries": "Entri ({count})", - "@logEntries": { - "description": "Total log count", - "placeholders": { - "count": { - "type": "int" - } - } - }, + "credentialsTitle": "Kredensial Spotify", - "@credentialsTitle": { - "description": "Credentials dialog title" - }, "credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.", - "@credentialsDescription": { - "description": "Credentials dialog explanation" - }, "credentialsClientId": "Client ID", - "@credentialsClientId": { - "description": "Client ID field label - DO NOT TRANSLATE" - }, "credentialsClientIdHint": "Tempel Client ID", - "@credentialsClientIdHint": { - "description": "Client ID placeholder" - }, "credentialsClientSecret": "Client Secret", - "@credentialsClientSecret": { - "description": "Client Secret field label - DO NOT TRANSLATE" - }, "credentialsClientSecretHint": "Tempel Client Secret", - "@credentialsClientSecretHint": { - "description": "Client Secret placeholder" - }, + "channelStable": "Stabil", - "@channelStable": { - "description": "Update channel - stable releases" - }, "channelPreview": "Preview", - "@channelPreview": { - "description": "Update channel - beta/preview releases" - }, + "sectionSearchSource": "Sumber Pencarian", - "@sectionSearchSource": { - "description": "Settings section header" - }, "sectionDownload": "Unduhan", - "@sectionDownload": { - "description": "Settings section header" - }, "sectionPerformance": "Performa", - "@sectionPerformance": { - "description": "Settings section header" - }, "sectionApp": "Aplikasi", - "@sectionApp": { - "description": "Settings section header" - }, "sectionData": "Data", - "@sectionData": { - "description": "Settings section header" - }, "sectionDebug": "Debug", - "@sectionDebug": { - "description": "Settings section header" - }, "sectionService": "Layanan", - "@sectionService": { - "description": "Settings section header" - }, "sectionAudioQuality": "Kualitas Audio", - "@sectionAudioQuality": { - "description": "Settings section header" - }, "sectionFileSettings": "Pengaturan File", - "@sectionFileSettings": { - "description": "Settings section header" - }, "sectionColor": "Warna", - "@sectionColor": { - "description": "Settings section header" - }, "sectionTheme": "Tema", - "@sectionTheme": { - "description": "Settings section header" - }, "sectionLayout": "Tata Letak", - "@sectionLayout": { - "description": "Settings section header" - }, "sectionLanguage": "Bahasa", - "@sectionLanguage": { - "description": "Settings section header for language" - }, + "appearanceLanguage": "Bahasa Aplikasi", - "@appearanceLanguage": { - "description": "Language setting title" - }, "appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, + "languageSystem": "Bawaan Sistem", + "languageEnglish": "English", + "languageIndonesian": "Bahasa Indonesia", + "settingsAppearanceSubtitle": "Tema, warna, tampilan", - "@settingsAppearanceSubtitle": { - "description": "Appearance settings description" - }, "settingsDownloadSubtitle": "Layanan, kualitas, format nama file", - "@settingsDownloadSubtitle": { - "description": "Download settings description" - }, "settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan", - "@settingsOptionsSubtitle": { - "description": "Options settings description" - }, "settingsExtensionsSubtitle": "Kelola provider unduhan", - "@settingsExtensionsSubtitle": { - "description": "Extensions settings description" - }, "settingsLogsSubtitle": "Lihat log aplikasi untuk debugging", - "@settingsLogsSubtitle": { - "description": "Logs settings description" - }, + "loadingSharedLink": "Memuat link yang dibagikan...", - "@loadingSharedLink": { - "description": "Status when opening shared URL" - }, "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", - "@pressBackAgainToExit": { - "description": "Exit confirmation message" - }, + + "artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}", + "artistCompilations": "Kompilasi", + "artistPopular": "Populer", + "artistMonthlyListeners": "{count} pendengar bulanan", + "tracksHeader": "Lagu", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Unduh Semua ({count})", - "@downloadAllCount": { - "description": "Download all button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", - "@tracksCount": { - "description": "Track count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trackCopyFilePath": "Salin lokasi file", - "@trackCopyFilePath": { - "description": "Action - copy file path" - }, - "trackRemoveFromDevice": "Hapus dari perangkat", - "@trackRemoveFromDevice": { - "description": "Action - delete downloaded file" - }, - "trackLoadLyrics": "Muat Lirik", - "@trackLoadLyrics": { - "description": "Action - fetch lyrics" - }, - "trackMetadata": "Metadata", - "@trackMetadata": { - "description": "Tab title - track metadata" - }, - "trackFileInfo": "Info File", - "@trackFileInfo": { - "description": "Tab title - file information" - }, - "trackLyrics": "Lirik", - "@trackLyrics": { - "description": "Tab title - lyrics" - }, - "trackFileNotFound": "File tidak ditemukan", - "@trackFileNotFound": { - "description": "Error - file doesn't exist" - }, - "trackOpenInDeezer": "Buka di Deezer", - "@trackOpenInDeezer": { - "description": "Action - open track in Deezer app" - }, - "trackOpenInSpotify": "Buka di Spotify", - "@trackOpenInSpotify": { - "description": "Action - open track in Spotify app" - }, - "trackTrackName": "Nama lagu", - "@trackTrackName": { - "description": "Metadata label - track title" - }, - "trackArtist": "Artis", - "@trackArtist": { - "description": "Metadata label - artist name" - }, - "trackAlbumArtist": "Artis album", - "@trackAlbumArtist": { - "description": "Metadata label - album artist" - }, - "trackAlbum": "Album", - "@trackAlbum": { - "description": "Metadata label - album name" - }, - "trackTrackNumber": "Nomor lagu", - "@trackTrackNumber": { - "description": "Metadata label - track number" - }, - "trackDiscNumber": "Nomor disc", - "@trackDiscNumber": { - "description": "Metadata label - disc number" - }, - "trackDuration": "Durasi", - "@trackDuration": { - "description": "Metadata label - track length" - }, - "trackAudioQuality": "Kualitas audio", - "@trackAudioQuality": { - "description": "Metadata label - audio quality" - }, - "trackReleaseDate": "Tanggal rilis", - "@trackReleaseDate": { - "description": "Metadata label - release date" - }, - "trackDownloaded": "Diunduh", - "@trackDownloaded": { - "description": "Metadata label - download date" - }, - "trackCopyLyrics": "Salin lirik", - "@trackCopyLyrics": { - "description": "Action - copy lyrics to clipboard" - }, - "trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini", - "@trackLyricsNotAvailable": { - "description": "Message when lyrics not found" - }, - "trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.", - "@trackLyricsTimeout": { - "description": "Message when lyrics request times out" - }, - "trackLyricsLoadFailed": "Gagal memuat lirik", - "@trackLyricsLoadFailed": { - "description": "Message when lyrics loading fails" - }, - "trackCopiedToClipboard": "Disalin ke clipboard", - "@trackCopiedToClipboard": { - "description": "Snackbar - content copied" - }, - "trackDeleteConfirmTitle": "Hapus dari perangkat?", - "@trackDeleteConfirmTitle": { - "description": "Delete confirmation title" - }, - "trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.", - "@trackDeleteConfirmMessage": { - "description": "Delete confirmation message" - }, - "trackCannotOpen": "Tidak dapat membuka: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, - "dateToday": "Hari ini", - "@dateToday": { - "description": "Relative date - today" - }, - "dateYesterday": "Kemarin", - "@dateYesterday": { - "description": "Relative date - yesterday" - }, - "dateDaysAgo": "{count} hari lalu", - "@dateDaysAgo": { - "description": "Relative date - days ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "dateWeeksAgo": "{count} minggu lalu", - "@dateWeeksAgo": { - "description": "Relative date - weeks ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "dateMonthsAgo": "{count} bulan lalu", - "@dateMonthsAgo": { - "description": "Relative date - months ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "concurrentSequential": "Berurutan", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Paralel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Paralel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Ketuk untuk melihat detail error", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, + + "setupStorageAccessRequired": "Akses Penyimpanan Diperlukan", + "setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.", + "setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.", + "setupOpenSettings": "Buka Pengaturan", + "setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.", + "setupPermissionRequired": "Izin {permissionType} Diperlukan", + "setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.", + "setupSelectDownloadFolder": "Pilih Folder Unduhan", + "setupUseDefaultFolder": "Gunakan Folder Default?", + "setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?", + "setupUseDefault": "Gunakan Default", + "setupDownloadLocationTitle": "Lokasi Unduhan", + "setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.", + "setupAppDocumentsFolder": "Folder Documents Aplikasi", + "setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files", + "setupChooseFromFiles": "Pilih dari Files", + "setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya", + "setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.", + "setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC", + "setupStepStorage": "Penyimpanan", + "setupStepNotification": "Notifikasi", + "setupStepFolder": "Folder", + "setupStepSpotify": "Spotify", + "setupStepPermission": "Izin", + "setupStorageGranted": "Izin Penyimpanan Diberikan!", + "setupStorageRequired": "Izin Penyimpanan Diperlukan", + "setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.", + "setupNotificationGranted": "Izin Notifikasi Diberikan!", + "setupNotificationEnable": "Aktifkan Notifikasi", + "setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.", + "setupFolderSelected": "Folder Unduhan Dipilih!", + "setupFolderChoose": "Pilih Folder Unduhan", + "setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.", + "setupChangeFolder": "Ubah Folder", + "setupSelectFolder": "Pilih Folder", + "setupSpotifyApiOptional": "Spotify API (Opsional)", + "setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.", + "setupUseSpotifyApi": "Gunakan Spotify API", + "setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah", + "setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)", + "setupEnterClientId": "Masukkan Spotify Client ID", + "setupEnterClientSecret": "Masukkan Spotify Client Secret", + "setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.", + "setupEnableNotifications": "Aktifkan Notifikasi", + + "dialogImport": "Impor", + "dialogDiscard": "Buang", + "dialogRemove": "Hapus", + "dialogUninstall": "Copot", + "dialogDiscardChanges": "Buang Perubahan?", + "dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?", + "dialogDownloadFailed": "Unduhan Gagal", + "dialogTrackLabel": "Lagu:", + "dialogArtistLabel": "Artis:", + "dialogErrorLabel": "Error:", + "dialogClearAll": "Hapus Semua", + "dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?", + "dialogRemoveFromDevice": "Hapus dari perangkat?", + "dialogRemoveExtension": "Hapus Ekstensi", + "dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.", + "dialogUninstallExtension": "Copot Ekstensi?", + "dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?", + + "snackbarFailedToLoad": "Gagal memuat: {error}", + "snackbarUrlCopied": "URL {platform} disalin ke clipboard", + "snackbarFileNotFound": "File tidak ditemukan", + "snackbarSelectExtFile": "Harap pilih file .spotiflac-ext", + "snackbarProviderPrioritySaved": "Prioritas provider disimpan", + "snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan", + "snackbarExtensionInstalled": "{extensionName} terpasang.", + "snackbarExtensionUpdated": "{extensionName} diperbarui.", + "snackbarFailedToInstall": "Gagal memasang ekstensi", + "snackbarFailedToUpdate": "Gagal memperbarui ekstensi", + "storeFilterAll": "Semua", - "@storeFilterAll": { - "description": "Store filter - all extensions" - }, "storeFilterMetadata": "Metadata", - "@storeFilterMetadata": { - "description": "Store filter - metadata providers" - }, "storeFilterDownload": "Unduhan", - "@storeFilterDownload": { - "description": "Store filter - download providers" - }, "storeFilterUtility": "Utilitas", - "@storeFilterUtility": { - "description": "Store filter - utility extensions" - }, "storeFilterLyrics": "Lirik", - "@storeFilterLyrics": { - "description": "Store filter - lyrics providers" - }, "storeFilterIntegration": "Integrasi", - "@storeFilterIntegration": { - "description": "Store filter - integrations" - }, "storeClearFilters": "Hapus filter", - "@storeClearFilters": { - "description": "Button to clear all filters" - }, "storeNoResults": "Tidak ada ekstensi ditemukan", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, + "extensionProviderPriority": "Prioritas Provider", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, "extensionInstallButton": "Pasang Ekstensi", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", - "@extensionDefaultProvider": { - "description": "Default search provider option" - }, "extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan", - "@extensionDefaultProviderSubtitle": { - "description": "Subtitle for default provider" - }, "extensionAuthor": "Pembuat", - "@extensionAuthor": { - "description": "Extension detail - author" - }, "extensionId": "ID", - "@extensionId": { - "description": "Extension detail - unique ID" - }, "extensionError": "Error", - "@extensionError": { - "description": "Extension detail - error message" - }, "extensionCapabilities": "Kemampuan", - "@extensionCapabilities": { - "description": "Section header - extension features" - }, "extensionMetadataProvider": "Provider Metadata", - "@extensionMetadataProvider": { - "description": "Capability - provides metadata" - }, "extensionDownloadProvider": "Provider Unduhan", - "@extensionDownloadProvider": { - "description": "Capability - provides downloads" - }, "extensionLyricsProvider": "Provider Lirik", - "@extensionLyricsProvider": { - "description": "Capability - provides lyrics" - }, "extensionUrlHandler": "Penanganan URL", - "@extensionUrlHandler": { - "description": "Capability - handles URLs" - }, "extensionQualityOptions": "Opsi Kualitas", - "@extensionQualityOptions": { - "description": "Capability - quality selection" - }, "extensionPostProcessingHooks": "Hook Pasca-Pemrosesan", - "@extensionPostProcessingHooks": { - "description": "Capability - post-processing" - }, "extensionPermissions": "Izin", - "@extensionPermissions": { - "description": "Section header - required permissions" - }, "extensionSettings": "Pengaturan", - "@extensionSettings": { - "description": "Section header - extension settings" - }, "extensionRemoveButton": "Hapus Ekstensi", - "@extensionRemoveButton": { - "description": "Button to uninstall extension" - }, "extensionUpdated": "Diperbarui", - "@extensionUpdated": { - "description": "Extension detail - last update" - }, "extensionMinAppVersion": "Versi App Minimum", - "@extensionMinAppVersion": { - "description": "Extension detail - minimum app version" - }, - "extensionCustomTrackMatching": "Pencocokan Lagu Kustom", - "@extensionCustomTrackMatching": { - "description": "Capability - custom track matching algorithm" - }, - "extensionPostProcessing": "Pasca-Pemrosesan", - "@extensionPostProcessing": { - "description": "Capability - post-download processing" - }, - "extensionHooksAvailable": "{count} hook tersedia", - "@extensionHooksAvailable": { - "description": "Post-processing hooks count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "extensionPatternsCount": "{count} pola", - "@extensionPatternsCount": { - "description": "URL patterns count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "extensionStrategy": "Strategi: {strategy}", - "@extensionStrategy": { - "description": "Track matching strategy name", - "placeholders": { - "strategy": { - "type": "String" - } - } - }, - "extensionsProviderPrioritySection": "Prioritas Provider", - "@extensionsProviderPrioritySection": { - "description": "Section header - provider priority" - }, - "extensionsInstalledSection": "Ekstensi Terpasang", - "@extensionsInstalledSection": { - "description": "Section header - installed extensions" - }, - "extensionsNoExtensions": "Tidak ada ekstensi terpasang", - "@extensionsNoExtensions": { - "description": "Empty state - no extensions" - }, - "extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru", - "@extensionsNoExtensionsSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsInstallButton": "Pasang Ekstensi", - "@extensionsInstallButton": { - "description": "Button to install extension from file" - }, - "extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.", - "@extensionsInfoTip": { - "description": "Security warning about extensions" - }, - "extensionsInstalledSuccess": "Ekstensi berhasil dipasang", - "@extensionsInstalledSuccess": { - "description": "Success message after install" - }, - "extensionsDownloadPriority": "Prioritas Unduhan", - "@extensionsDownloadPriority": { - "description": "Setting - download provider order" - }, - "extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan", - "@extensionsDownloadPrioritySubtitle": { - "description": "Subtitle for download priority" - }, - "extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan", - "@extensionsNoDownloadProvider": { - "description": "Empty state - no download providers" - }, - "extensionsMetadataPriority": "Prioritas Metadata", - "@extensionsMetadataPriority": { - "description": "Setting - metadata provider order" - }, - "extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata", - "@extensionsMetadataPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, - "extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata", - "@extensionsNoMetadataProvider": { - "description": "Empty state - no metadata providers" - }, - "extensionsSearchProvider": "Provider Pencarian", - "@extensionsSearchProvider": { - "description": "Setting - search provider selection" - }, - "extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom", - "@extensionsNoCustomSearch": { - "description": "Empty state - no search providers" - }, - "extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu", - "@extensionsSearchProviderDescription": { - "description": "Search provider setting description" - }, - "extensionsCustomSearch": "Pencarian kustom", - "@extensionsCustomSearch": { - "description": "Label for custom search provider" - }, - "extensionsErrorLoading": "Error memuat ekstensi", - "@extensionsErrorLoading": { - "description": "Error message when extension fails to load" - }, + "qualityFlacLossless": "FLAC Lossless", - "@qualityFlacLossless": { - "description": "Quality option - CD quality FLAC" - }, "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", - "@qualityFlacLosslessSubtitle": { - "description": "Technical spec for lossless" - }, "qualityHiResFlac": "Hi-Res FLAC", - "@qualityHiResFlac": { - "description": "Quality option - high resolution FLAC" - }, "qualityHiResFlacSubtitle": "24-bit / hingga 96kHz", - "@qualityHiResFlacSubtitle": { - "description": "Technical spec for hi-res" - }, "qualityHiResFlacMax": "Hi-Res FLAC Max", - "@qualityHiResFlacMax": { - "description": "Quality option - maximum resolution FLAC" - }, "qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz", - "@qualityHiResFlacMaxSubtitle": { - "description": "Technical spec for hi-res max" - }, + "qualityMp3": "MP3", + "qualityMp3Subtitle": "320kbps (konversi dari FLAC)", + "enableMp3Option": "Aktifkan Opsi MP3", + "enableMp3OptionSubtitleOn": "Opsi kualitas MP3 tersedia", + "enableMp3OptionSubtitleOff": "Unduh FLAC lalu konversi ke MP3 320kbps", "qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan", - "@qualityNote": { - "description": "Note about quality availability" - }, + "downloadAskBeforeDownload": "Tanya Sebelum Unduh", - "@downloadAskBeforeDownload": { - "description": "Setting - show quality picker" - }, "downloadDirectory": "Direktori Unduhan", - "@downloadDirectory": { - "description": "Setting - download folder" - }, "downloadSeparateSinglesFolder": "Folder Singles Terpisah", - "@downloadSeparateSinglesFolder": { - "description": "Setting - separate folder for singles" - }, "downloadAlbumFolderStructure": "Struktur Folder Album", - "@downloadAlbumFolderStructure": { - "description": "Setting - album folder organization" - }, "downloadSaveFormat": "Simpan Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, "downloadSelectService": "Pilih Layanan", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Pilih Kualitas", - "@downloadSelectQuality": { - "description": "Dialog title - choose audio quality" - }, "downloadFrom": "Unduh Dari", - "@downloadFrom": { - "description": "Label - download source" - }, "downloadDefaultQualityLabel": "Kualitas Default", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, "downloadBestAvailable": "Terbaik tersedia", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, + "folderNone": "Tidak ada", - "@folderNone": { - "description": "Folder option - no organization" - }, "folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, "folderArtist": "Artis", - "@folderArtist": { - "description": "Folder option - by artist" - }, "folderArtistSubtitle": "Nama Artis/namafile", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, "folderAlbumSubtitle": "Nama Album/namafile", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, "folderArtistAlbum": "Artis/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, "folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, + "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, + + "logSearchHint": "Cari log...", + "logFilterLevel": "Level", + "logFilterSection": "Filter", + "logShareLogs": "Bagikan log", + "logClearLogs": "Hapus log", + "logClearLogsTitle": "Hapus Log", + "logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?", + "logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI", + "logRateLimited": "DIBATASI", + "logNetworkError": "ERROR JARINGAN", + "logTrackNotFound": "LAGU TIDAK DITEMUKAN", + "appearanceAmoledDark": "AMOLED Gelap", - "@appearanceAmoledDark": { - "description": "Theme option - pure black" - }, "appearanceAmoledDarkSubtitle": "Latar belakang hitam murni", - "@appearanceAmoledDarkSubtitle": { - "description": "Subtitle for AMOLED dark" - }, "appearanceChooseAccentColor": "Pilih Warna Aksen", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, "appearanceChooseTheme": "Mode Tema", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, + + "updateStartingDownload": "Memulai unduhan...", + "updateDownloadFailed": "Unduhan gagal", + "updateFailedMessage": "Gagal mengunduh pembaruan", + "updateNewVersionReady": "Versi baru sudah siap", + "updateCurrent": "Saat ini", + "updateNew": "Baru", + "updateDownloading": "Mengunduh...", + "updateWhatsNew": "Yang Baru", + "updateDownloadInstall": "Unduh & Pasang", + "updateDontRemind": "Jangan ingatkan", + + "trackCopyFilePath": "Salin lokasi file", + "trackRemoveFromDevice": "Hapus dari perangkat", + "trackLoadLyrics": "Muat Lirik", + + "dateToday": "Hari ini", + "dateYesterday": "Kemarin", + "dateDaysAgo": "{count} hari lalu", + "dateWeeksAgo": "{count} minggu lalu", + "dateMonthsAgo": "{count} bulan lalu", + + "concurrentSequential": "Berurutan", + "concurrentParallel2": "2 Paralel", + "concurrentParallel3": "3 Paralel", + + "filenameAvailablePlaceholders": "Placeholder yang tersedia:", + "filenameHint": "{artist} - {title}", + + "tapToSeeError": "Ketuk untuk melihat detail error", + + "setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.", + "setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.", + "setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.", + "setupSkipForNow": "Lewati untuk sekarang", + "setupBack": "Kembali", + "setupNext": "Lanjut", + "setupGetStarted": "Mulai", + "setupSkipAndStart": "Lewati & Mulai", + "setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.", + "setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com", + + "trackMetadata": "Metadata", + "trackFileInfo": "Info File", + "trackLyrics": "Lirik", + "trackFileNotFound": "File tidak ditemukan", + "trackOpenInDeezer": "Buka di Deezer", + "trackOpenInSpotify": "Buka di Spotify", + "trackTrackName": "Nama lagu", + "trackArtist": "Artis", + "trackAlbumArtist": "Artis album", + "trackAlbum": "Album", + "trackTrackNumber": "Nomor lagu", + "trackDiscNumber": "Nomor disc", + "trackDuration": "Durasi", + "trackAudioQuality": "Kualitas audio", + "trackReleaseDate": "Tanggal rilis", + "trackDownloaded": "Diunduh", + "trackCopyLyrics": "Salin lirik", + "trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini", + "trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.", + "trackLyricsLoadFailed": "Gagal memuat lirik", + "trackCopiedToClipboard": "Disalin ke clipboard", + "trackDeleteConfirmTitle": "Hapus dari perangkat?", + "trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.", + "trackCannotOpen": "Tidak dapat membuka: {message}", + + "logFilterBySeverity": "Filter log berdasarkan tingkat keparahan", + "logNoLogsYet": "Belum ada log", + "logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi", + "logIssueSummary": "Ringkasan Masalah", + "logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan", + "logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8", + "logRateLimitedDescription": "Terlalu banyak permintaan ke layanan", + "logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi", + "logNetworkErrorDescription": "Masalah koneksi terdeteksi", + "logNetworkErrorSuggestion": "Periksa koneksi internet Anda", + "logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan", + "logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless", + "logTotalErrors": "Total error: {count}", + "logAffected": "Terpengaruh: {domains}", + "logEntriesFiltered": "Entri ({count} difilter)", + "logEntries": "Entri ({count})", + + "extensionsProviderPrioritySection": "Prioritas Provider", + "extensionsInstalledSection": "Ekstensi Terpasang", + "extensionsNoExtensions": "Tidak ada ekstensi terpasang", + "extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru", + "extensionsInstallButton": "Pasang Ekstensi", + "extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.", + "extensionsInstalledSuccess": "Ekstensi berhasil dipasang", + "extensionsDownloadPriority": "Prioritas Unduhan", + "extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan", + "extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan", + "extensionsMetadataPriority": "Prioritas Metadata", + "extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata", + "extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata", + "extensionsSearchProvider": "Provider Pencarian", + "extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom", + "extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu", + "extensionsCustomSearch": "Pencarian kustom", + "extensionsErrorLoading": "Error memuat ekstensi", + + "extensionCustomTrackMatching": "Pencocokan Lagu Kustom", + "extensionPostProcessing": "Pasca-Pemrosesan", + "extensionHooksAvailable": "{count} hook tersedia", + "extensionPatternsCount": "{count} pola", + "extensionStrategy": "Strategi: {strategy}", + + "aboutDoubleDouble": "DoubleDouble", + "aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!", + "aboutDabMusic": "DAB Music", + "aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!", + "queueTitle": "Antrian Unduhan", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Hapus Semua", - "@queueClearAll": { - "description": "Button - clear all queue items" - }, "queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?", - "@queueClearAllMessage": { - "description": "Clear queue confirmation" - }, - "queueEmpty": "Tidak ada unduhan dalam antrian", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Tambahkan lagu dari layar beranda", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Hapus yang selesai", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Unduhan Gagal", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Lagu:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artis:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Error tidak diketahui", - "@queueUnknownError": { - "description": "Fallback error message" - }, + "albumFolderArtistAlbum": "Artis / Album", - "@albumFolderArtistAlbum": { - "description": "Album folder option" - }, "albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/", - "@albumFolderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, "albumFolderArtistYearAlbum": "Artis / [Tahun] Album", - "@albumFolderArtistYearAlbum": { - "description": "Album folder option with year" - }, "albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/", - "@albumFolderArtistYearAlbumSubtitle": { - "description": "Folder structure example" - }, "albumFolderAlbumOnly": "Album Saja", - "@albumFolderAlbumOnly": { - "description": "Album folder option" - }, "albumFolderAlbumOnlySubtitle": "Albums/Nama Album/", - "@albumFolderAlbumOnlySubtitle": { - "description": "Folder structure example" - }, "albumFolderYearAlbum": "[Tahun] Album", - "@albumFolderYearAlbum": { - "description": "Album folder option with year" - }, "albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/", - "@albumFolderYearAlbumSubtitle": { - "description": "Folder structure example" - }, + "downloadedAlbumDeleteSelected": "Hapus yang Dipilih", - "@downloadedAlbumDeleteSelected": { - "description": "Button - delete selected tracks" - }, "downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.", - "@downloadedAlbumDeleteMessage": { - "description": "Delete confirmation with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "downloadedAlbumTracksHeader": "Lagu", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} diunduh", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "downloadedAlbumSelectedCount": "{count} dipilih", - "@downloadedAlbumSelectedCount": { - "description": "Selection count indicator", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "downloadedAlbumAllSelected": "Semua lagu dipilih", - "@downloadedAlbumAllSelected": { - "description": "Status - all items selected" - }, - "downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih", - "@downloadedAlbumTapToSelect": { - "description": "Selection hint" - }, - "downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", - "@downloadedAlbumDeleteCount": { - "description": "Delete button text with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus", - "@downloadedAlbumSelectToDelete": { - "description": "Placeholder when nothing selected" - }, + "utilityFunctions": "Fungsi Utilitas", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, + + "aboutMobileDeveloper": "Pengembang versi mobile", + "aboutOriginalCreator": "Pembuat SpotiFLAC asli", + "aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!", + "aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!", + "aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!", + "aboutMobileSource": "Kode sumber mobile", + "aboutPCSource": "Kode sumber PC", + "aboutReportIssue": "Laporkan masalah", + "aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui", + "aboutFeatureRequest": "Permintaan fitur", + "aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi", + "aboutBuyMeCoffee": "Belikan saya kopi", + "aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi", + "aboutVersion": "Versi", + "aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", + + "providerPriorityTitle": "Prioritas Provider", + "providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.", + "providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.", + "providerBuiltIn": "Bawaan", + "providerExtension": "Ekstensi", + + "metadataProviderPriorityTitle": "Prioritas Metadata", + "metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.", + "metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.", + "metadataNoRateLimits": "Tidak ada batas rate", + "metadataMayRateLimit": "Mungkin dibatasi rate", + + "queueEmpty": "Tidak ada unduhan dalam antrian", + "queueEmptySubtitle": "Tambahkan lagu dari layar beranda", + "queueClearCompleted": "Hapus yang selesai", + "queueDownloadFailed": "Unduhan Gagal", + "queueTrackLabel": "Lagu:", + "queueArtistLabel": "Artis:", + "queueErrorLabel": "Error:", + "queueUnknownError": "Error tidak diketahui", + + "downloadedAlbumTracksHeader": "Lagu", + "downloadedAlbumDownloadedCount": "{count} diunduh", + "downloadedAlbumSelectedCount": "{count} dipilih", + "downloadedAlbumAllSelected": "Semua lagu dipilih", + "downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih", + "downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", + "downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus", + "downloadedAlbumDiscHeader": "Disc {discNumber}", + + "folderOrganizationDescription": "Atur file yang diunduh ke dalam folder", + "folderOrganizationNone": "Tidak ada", + "folderOrganizationNoneSubtitle": "Semua file di folder unduhan", + "folderOrganizationByArtist": "Berdasarkan Artis", + "folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis", + "folderOrganizationByAlbum": "Berdasarkan Album", + "folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album", + "folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album", + "folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album", + "recentTypeArtist": "Artis", - "@recentTypeArtist": { - "description": "Recent access item type - artist" - }, "recentTypeAlbum": "Album", - "@recentTypeAlbum": { - "description": "Recent access item type - album" - }, "recentTypeSong": "Lagu", - "@recentTypeSong": { - "description": "Recent access item type - song/track" - }, "recentTypePlaylist": "Playlist", - "@recentTypePlaylist": { - "description": "Recent access item type - playlist" - }, + "recentPlaylistInfo": "Playlist: {name}", - "@recentPlaylistInfo": { - "description": "Snackbar message when tapping playlist in recent access", - "placeholders": { - "name": { - "type": "String", - "description": "Playlist name" - } - } - }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - } -} \ No newline at end of file + "errorGeneric": "Error: {message}" +} diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index e7dfeecb..cf315776 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -85,7 +85,7 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", + "historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "@historyTracksCount": { "description": "Track count with plural form", "placeholders": { @@ -94,7 +94,7 @@ } } }, - "historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} =1 {1 альбом} other {{count} альбомов}}", + "historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}", "@historyAlbumsCount": { "description": "Album count with plural form", "placeholders": { @@ -596,7 +596,7 @@ "@albumTitle": { "description": "Album screen title" }, - "albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", + "albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "@albumTracks": { "description": "Album track count", "placeholders": { @@ -633,7 +633,7 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} =1 {1 релиз} other {{count} релизов}}", + "artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}", "@artistReleases": { "description": "Artist release count", "placeholders": { @@ -1108,7 +1108,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", + "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -1169,7 +1169,7 @@ "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -1376,7 +1376,7 @@ "@selectionTapToSelect": { "description": "Hint - how to select items" }, - "selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@selectionDeleteTracks": { "description": "Delete button with count", "placeholders": { @@ -1916,7 +1916,7 @@ } } }, - "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", + "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -2520,7 +2520,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", + "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -2559,7 +2559,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 6b0b891a..0ff45cdb 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -31,6 +31,7 @@ class AppSettings { final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album final bool showExtensionStore; // Show Extension Store tab in navigation final String locale; // App language: 'system', 'en', 'id', etc. + final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion) const AppSettings({ this.defaultService = 'tidal', @@ -60,6 +61,7 @@ class AppSettings { this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album this.showExtensionStore = true, // Default: show store this.locale = 'system', // Default: follow system language + this.enableMp3Option = false, // Default: disabled }); AppSettings copyWith({ @@ -91,6 +93,7 @@ class AppSettings { String? albumFolderStructure, bool? showExtensionStore, String? locale, + bool? enableMp3Option, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -120,6 +123,7 @@ class AppSettings { albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, showExtensionStore: showExtensionStore ?? this.showExtensionStore, locale: locale ?? this.locale, + enableMp3Option: enableMp3Option ?? this.enableMp3Option, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 6094a638..96962fa7 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -36,6 +36,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( json['albumFolderStructure'] as String? ?? 'artist_album', showExtensionStore: json['showExtensionStore'] as bool? ?? true, locale: json['locale'] as String? ?? 'system', + enableMp3Option: json['enableMp3Option'] as bool? ?? false, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -67,4 +68,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'albumFolderStructure': instance.albumFolderStructure, 'showExtensionStore': instance.showExtensionStore, 'locale': instance.locale, + 'enableMp3Option': instance.enableMp3Option, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 2712251d..b94125b6 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -588,6 +588,8 @@ class DownloadQueueNotifier extends Notifier { } } } catch (e) { + // Silently ignore polling errors to avoid spamming logs + // Polling is not critical and will retry on next interval } }); } @@ -1126,13 +1128,139 @@ class DownloadQueueNotifier extends Notifier { if (await coverFile.exists()) { await coverFile.delete(); } - } catch (_) {} + } catch (e) { + _log.w('Failed to cleanup cover file: $e'); + } } } catch (e) { _log.e('Failed to embed metadata: $e'); } } + /// Embed metadata, lyrics, and cover to a MP3 file + Future _embedMetadataToMp3(String mp3Path, Track track) async { + final settings = ref.read(settingsProvider); + + String? coverPath; + var coverUrl = track.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { + try { + if (settings.maxQualityCover) { + coverUrl = _upgradeToMaxQualityCover(coverUrl); + _log.d('Cover URL upgraded to max quality for MP3: $coverUrl'); + } + + final tempDir = await getTemporaryDirectory(); + final uniqueId = + '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; + coverPath = '${tempDir.path}/cover_mp3_$uniqueId.jpg'; + + final httpClient = HttpClient(); + final request = await httpClient.getUrl(Uri.parse(coverUrl)); + final response = await request.close(); + if (response.statusCode == 200) { + final file = File(coverPath); + final sink = file.openWrite(); + await response.pipe(sink); + await sink.close(); + _log.d('Cover downloaded for MP3: $coverPath'); + } else { + _log.w('Failed to download cover for MP3: HTTP ${response.statusCode}'); + coverPath = null; + } + httpClient.close(); + } catch (e) { + _log.e('Failed to download cover for MP3: $e'); + coverPath = null; + } + } + + try { + final metadata = { + 'TITLE': track.name, + 'ARTIST': track.artistName, + 'ALBUM': track.albumName, + }; + + final albumArtist = _normalizeOptionalString(track.albumArtist) ?? + track.artistName; + metadata['ALBUMARTIST'] = albumArtist; + + if (track.trackNumber != null) { + metadata['TRACKNUMBER'] = track.trackNumber.toString(); + metadata['TRACK'] = track.trackNumber.toString(); + } + + if (track.discNumber != null) { + metadata['DISCNUMBER'] = track.discNumber.toString(); + metadata['DISC'] = track.discNumber.toString(); + } + + if (track.releaseDate != null) { + metadata['DATE'] = track.releaseDate!; + metadata['YEAR'] = track.releaseDate!.split('-').first; + } + + if (track.isrc != null) { + metadata['ISRC'] = track.isrc!; + } + + _log.d('MP3 Metadata map content: $metadata'); + + // Fetch lyrics if embedLyrics is enabled + if (settings.embedLyrics) { + try { + final durationMs = track.duration * 1000; + + final lrcContent = await PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + filePath: '', + durationMs: durationMs, + ); + + if (lrcContent.isNotEmpty) { + metadata['LYRICS'] = lrcContent; + metadata['UNSYNCEDLYRICS'] = lrcContent; + _log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)'); + } + } catch (e) { + _log.w('Failed to fetch lyrics for MP3 embedding: $e'); + } + } + + _log.d('Embedding tags to MP3: $metadata'); + + final result = await FFmpegService.embedMetadataToMp3( + mp3Path: mp3Path, + coverPath: coverPath != null && await File(coverPath).exists() + ? coverPath + : null, + metadata: metadata, + ); + + if (result != null) { + _log.d('Metadata, lyrics, and cover embedded to MP3 via FFmpeg'); + } else { + _log.w('FFmpeg MP3 metadata/cover embed failed'); + } + + if (coverPath != null) { + try { + final coverFile = File(coverPath); + if (await coverFile.exists()) { + await coverFile.delete(); + } + } catch (e) { + _log.w('Failed to cleanup MP3 cover file: $e'); + } + } + } catch (e) { + _log.e('Failed to embed metadata to MP3: $e'); + } + } + Future _processQueue() async { if (state.isProcessing) return; // Prevent multiple concurrent processing @@ -1677,6 +1805,43 @@ class DownloadQueueNotifier extends Notifier { return; } + // Convert FLAC to MP3 if MP3 quality was selected + if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) { + _log.i('MP3 quality selected, converting FLAC to MP3...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.97, + ); + + try { + final mp3Path = await FFmpegService.convertFlacToMp3( + filePath, + bitrate: '320k', + deleteOriginal: true, + ); + + if (mp3Path != null) { + filePath = mp3Path; + actualQuality = 'MP3 320kbps'; + _log.i('Successfully converted to MP3: $mp3Path'); + + // Embed metadata, lyrics, and cover to the MP3 file + _log.i('Embedding metadata to MP3...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.99, + ); + await _embedMetadataToMp3(mp3Path, trackToDownload); + } else { + _log.w('MP3 conversion failed, keeping FLAC file'); + } + } catch (e) { + _log.e('MP3 conversion error: $e, keeping FLAC file'); + } + } + updateItemStatus( item.id, DownloadStatus.completed, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index a5dd74c1..bef594e9 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -223,6 +223,15 @@ class SettingsNotifier extends Notifier { state = state.copyWith(locale: locale); _saveSettings(); } + + void setEnableMp3Option(bool enabled) { + state = state.copyWith(enableMp3Option: enabled); + // If MP3 is disabled and current quality is MP3, reset to LOSSLESS + if (!enabled && state.audioQuality == 'MP3') { + state = state.copyWith(audioQuality: 'LOSSLESS'); + } + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index a37bd60c..4534d400 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -477,6 +477,8 @@ class TrackNotifier extends Notifier { tracks[index] = updatedTrack; state = state.copyWith(tracks: tracks); } catch (e) { + // Silently ignore availability check errors + // This is a background operation that shouldn't disrupt the user } } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 0b087645..aad638f5 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:palette_generator/palette_generator.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -60,11 +61,16 @@ class _AlbumScreenState extends ConsumerState { List? _tracks; bool _isLoading = false; String? _error; + Color? _dominantColor; + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); + _scrollController.addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'; ref.read(recentAccessProvider.notifier).recordAlbumAccess( @@ -80,6 +86,42 @@ class _AlbumScreenState extends ConsumerState { if (_tracks == null) { _fetchTracks(); } + + _extractDominantColor(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + // Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top) + final shouldShow = _scrollController.offset > 280; + if (shouldShow != _showTitleInAppBar) { + setState(() => _showTitleInAppBar = shouldShow); + } + } + + Future _extractDominantColor() async { + if (widget.coverUrl == null) return; + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(widget.coverUrl!), + maximumColorCount: 16, + ); + if (mounted) { + setState(() { + _dominantColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + }); + } + } catch (_) { + // Ignore palette extraction errors + } } Future _fetchTracks() async { @@ -143,6 +185,7 @@ class _AlbumScreenState extends ConsumerState { return Scaffold( body: CustomScrollView( + controller: _scrollController, slivers: [ _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme), @@ -167,74 +210,105 @@ class _AlbumScreenState extends ConsumerState { } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + final screenWidth = MediaQuery.of(context).size.width; + final coverSize = screenWidth * 0.5; // 50% of screen width + final bgColor = _dominantColor ?? colorScheme.surface; + return SliverAppBar( - expandedHeight: 280, + expandedHeight: 320, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.surface, // Use theme color for collapsed state surfaceTintColor: Colors.transparent, - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - if (widget.coverUrl != null) - CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - color: Colors.black.withValues(alpha: 0.5), - colorBlendMode: BlendMode.darken, - memCacheWidth: 600, - ), - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - colorScheme.surface.withValues(alpha: 0.8), - colorScheme.surface, - ], - stops: const [0.0, 0.7, 1.0], - ), - ), - ), - Center( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Container( - width: 140, - height: 140, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: widget.coverUrl != null - ? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant), - ), - ), - ), - ), - ), - ], + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + widget.albumName, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); + final showContent = collapseRatio > 0.3; + + return FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + // Background with dominant color + AnimatedContainer( + duration: const Duration(milliseconds: 500), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + bgColor, + bgColor.withValues(alpha: 0.8), + colorScheme.surface, + ], + stops: const [0.0, 0.6, 1.0], + ), + ), + ), + // Cover image centered - fade out when collapsing + AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 30, + offset: const Offset(0, 15), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: widget.coverUrl != null + ? CachedNetworkImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: (coverSize * 2).toInt(), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), + ), + ), + ), + ), + ), + ), + ], + ), + stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + ); + }, ), leading: IconButton( icon: Container( padding: const EdgeInsets.all(8), - decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.8), + shape: BoxShape.circle, + ), child: Icon(Icons.arrow_back, color: colorScheme.onSurface), ), onPressed: () => Navigator.pop(context), @@ -244,6 +318,8 @@ class _AlbumScreenState extends ConsumerState { Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { final tracks = _tracks ?? []; + final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; + return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), @@ -260,7 +336,14 @@ class _AlbumScreenState extends ConsumerState { widget.albumName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), ), - const SizedBox(height: 8), + if (artistName != null && artistName.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + artistName, + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + const SizedBox(height: 12), if (tracks.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index b2427ce9..23b21cc1 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -95,11 +95,18 @@ class _ArtistScreenState extends ConsumerState { String? _headerImageUrl; int? _monthlyListeners; String? _error; + + // Sticky title state + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); - @override +@override void initState() { super.initState(); + // Setup scroll listener for sticky title + _scrollController.addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { final providerId = widget.extensionId ?? (widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify'); @@ -141,9 +148,24 @@ class _ArtistScreenState extends ConsumerState { } } else { _fetchDiscography(); +} + } + + void _onScroll() { + // Show title when scrolled past the header (280px trigger) + final shouldShow = _scrollController.offset > 280; + if (shouldShow != _showTitleInAppBar) { + setState(() => _showTitleInAppBar = shouldShow); } } + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + Future _fetchDiscography() async { setState(() => _isLoadingDiscography = true); try { @@ -256,8 +278,9 @@ class _ArtistScreenState extends ConsumerState { final singles = albums.where((a) => a.albumType == 'single').toList(); final compilations = albums.where((a) => a.albumType == 'compilation').toList(); - return Scaffold( +return Scaffold( body: CustomScrollView( + controller: _scrollController, slivers: [ _buildHeader(context, colorScheme), if (_isLoadingDiscography) @@ -307,12 +330,20 @@ class _ArtistScreenState extends ConsumerState { listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners)); } - return SliverAppBar( +return SliverAppBar( expandedHeight: 380, pinned: true, stretch: true, backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + widget.artistName, + style: TextStyle(color: colorScheme.onSurface), + ), + ), flexibleSpace: FlexibleSpaceBar( background: Stack( fit: StackFit.expand, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index c10bb466..6031687c 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; @@ -29,6 +30,49 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget { class _DownloadedAlbumScreenState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedIds = {}; + Color? _dominantColor; + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _extractDominantColor(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + final shouldShow = _scrollController.offset > 280; + if (shouldShow != _showTitleInAppBar) { + setState(() => _showTitleInAppBar = shouldShow); + } + } + + Future _extractDominantColor() async { + if (widget.coverUrl == null) return; + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(widget.coverUrl!), + maximumColorCount: 16, + ); + if (mounted) { + setState(() { + _dominantColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + }); + } + } catch (_) { + // Ignore palette extraction errors + } + } /// Get tracks for this album from history provider (reactive) List _getAlbumTracks(List allItems) { @@ -38,6 +82,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { return itemKey == albumKey; }).toList() ..sort((a, b) { + // Sort by disc number first, then by track number + final aDisc = a.discNumber ?? 1; + final bDisc = b.discNumber ?? 1; + if (aDisc != bDisc) return aDisc.compareTo(bDisc); final aNum = a.trackNumber ?? 999; final bNum = b.trackNumber ?? 999; if (aNum != bNum) return aNum.compareTo(bNum); @@ -45,6 +93,26 @@ class _DownloadedAlbumScreenState extends ConsumerState { }); } + /// Get unique disc numbers from tracks (sorted) + List _getDiscNumbers(List tracks) { + final discNumbers = tracks + .map((t) => t.discNumber ?? 1) + .toSet() + .toList() + ..sort(); + return discNumbers; + } + + /// Check if album has multiple discs + bool _hasMultipleDiscs(List tracks) { + return _getDiscNumbers(tracks).length > 1; + } + + /// Get tracks for a specific disc + List _getTracksForDisc(List tracks, int discNumber) { + return tracks.where((t) => (t.discNumber ?? 1) == discNumber).toList(); + } + void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); setState(() { @@ -187,6 +255,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { body: Stack( children: [ CustomScrollView( + controller: _scrollController, slivers: [ _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme, tracks), @@ -211,69 +280,97 @@ class _DownloadedAlbumScreenState extends ConsumerState { } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + final screenWidth = MediaQuery.of(context).size.width; + final coverSize = screenWidth * 0.5; // 50% of screen width + final bgColor = _dominantColor ?? colorScheme.surface; + return SliverAppBar( - expandedHeight: 280, + expandedHeight: 320, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.surface, // Use theme color for collapsed state surfaceTintColor: Colors.transparent, - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - if (widget.coverUrl != null) - CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - color: Colors.black.withValues(alpha: 0.5), - colorBlendMode: BlendMode.darken, - memCacheWidth: 600, - ), - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - colorScheme.surface.withValues(alpha: 0.8), - colorScheme.surface, - ], - stops: const [0.0, 0.7, 1.0], - ), - ), - ), - Center( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Container( - width: 140, - height: 140, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: widget.coverUrl != null - ? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant), - ), - ), - ), - ), - ), - ], + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + widget.albumName, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); + final showContent = collapseRatio > 0.3; + + return FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + // Background with dominant color + AnimatedContainer( + duration: const Duration(milliseconds: 500), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + bgColor, + bgColor.withValues(alpha: 0.8), + colorScheme.surface, + ], + stops: const [0.0, 0.6, 1.0], + ), + ), + ), + // Cover image centered - fade out when collapsing + AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 30, + offset: const Offset(0, 15), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: widget.coverUrl != null + ? CachedNetworkImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: (coverSize * 2).toInt(), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), + ), + ), + ), + ), + ), + ), + ], + ), + stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + ); + }, ), leading: IconButton( icon: Container( @@ -388,16 +485,84 @@ class _DownloadedAlbumScreenState extends ConsumerState { } Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final track = tracks[index]; - return KeyedSubtree( + // Check if album has multiple discs + if (!_hasMultipleDiscs(tracks)) { + // Single disc - use simple list + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final track = tracks[index]; + return KeyedSubtree( + key: ValueKey(track.id), + child: _buildTrackItem(context, colorScheme, track), + ); + }, + childCount: tracks.length, + ), + ); + } + + // Multiple discs - build list with separators + final discNumbers = _getDiscNumbers(tracks); + final List children = []; + + for (final discNumber in discNumbers) { + final discTracks = _getTracksForDisc(tracks, discNumber); + if (discTracks.isEmpty) continue; + + // Add disc separator + children.add(_buildDiscSeparator(context, colorScheme, discNumber)); + + // Add tracks for this disc + for (final track in discTracks) { + children.add( + KeyedSubtree( key: ValueKey(track.id), child: _buildTrackItem(context, colorScheme, track), - ); - }, - childCount: tracks.length, + ), + ); + } + } + + return SliverList( + delegate: SliverChildListDelegate(children), + ); + } + + Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer), + const SizedBox(width: 6), + Text( + context.l10n.downloadedAlbumDiscHeader(discNumber), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + ], ), ); } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index bdabe145..06e7c27c 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; +import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -650,12 +651,25 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Widget _buildRecentAccess(List items, ColorScheme colorScheme) { final historyItems = ref.read(downloadHistoryProvider).items; - final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem( - id: h.spotifyId!, - name: h.trackName, - subtitle: h.artistName, + // Group download history by album to avoid flooding recents with individual tracks + final albumMap = {}; + for (final h in historyItems) { + // Use album name + artist as unique key + final albumKey = '${h.albumName}|${h.albumArtist ?? h.artistName}'; + // Keep the most recent download for each album + if (!albumMap.containsKey(albumKey) || + h.downloadedAt.isAfter(albumMap[albumKey]!.downloadedAt)) { + albumMap[albumKey] = h; + } + } + + // Convert grouped albums to RecentAccessItem with album type + final downloadItems = albumMap.values.take(10).map((h) => RecentAccessItem( + id: '${h.albumName}|${h.albumArtist ?? h.artistName}', // Use album key as ID + name: h.albumName, + subtitle: h.albumArtist ?? h.artistName, imageUrl: h.coverUrl, - type: RecentAccessType.track, + type: RecentAccessType.album, accessedAt: h.downloadedAt, providerId: 'download', )).toList(); @@ -815,7 +829,16 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } case RecentAccessType.album: - if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') { + // Handle downloaded albums - navigate to DownloadedAlbumScreen + if (item.providerId == 'download') { + Navigator.push(context, MaterialPageRoute( + builder: (context) => DownloadedAlbumScreen( + albumName: item.name, + artistName: item.subtitle ?? '', + coverUrl: item.imageUrl, + ), + )); + } else if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') { Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: item.providerId!, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 56162437..3ff948f6 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:palette_generator/palette_generator.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -10,7 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; /// Playlist detail screen with Material Expressive 3 design -class PlaylistScreen extends ConsumerWidget { +class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; final String? coverUrl; final List tracks; @@ -23,16 +24,66 @@ class PlaylistScreen extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _PlaylistScreenState(); +} + +class _PlaylistScreenState extends ConsumerState { + Color? _dominantColor; + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _extractDominantColor(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + final shouldShow = _scrollController.offset > 280; + if (shouldShow != _showTitleInAppBar) { + setState(() => _showTitleInAppBar = shouldShow); + } + } + + Future _extractDominantColor() async { + if (widget.coverUrl == null) return; + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(widget.coverUrl!), + maximumColorCount: 16, + ); + if (mounted) { + setState(() { + _dominantColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + }); + } + } catch (_) { + // Ignore palette extraction errors + } + } + + @override + Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Scaffold( body: CustomScrollView( + controller: _scrollController, slivers: [ _buildAppBar(context, colorScheme), - _buildInfoCard(context, ref, colorScheme), + _buildInfoCard(context, colorScheme), _buildTrackListHeader(context, colorScheme), - _buildTrackList(context, ref, colorScheme), + _buildTrackList(context, colorScheme), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), @@ -40,59 +91,113 @@ class PlaylistScreen extends ConsumerWidget { } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + final screenWidth = MediaQuery.of(context).size.width; + final coverSize = screenWidth * 0.5; // 50% of screen width + final bgColor = _dominantColor ?? colorScheme.surface; + return SliverAppBar( - expandedHeight: 280, + expandedHeight: 320, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.surface, // Use theme color for collapsed state surfaceTintColor: Colors.transparent, - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - if (coverUrl != null) - CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600), - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface], - stops: const [0.0, 0.7, 1.0], - ), - ), - ), - Center( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Container( - width: 140, - height: 140, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: coverUrl != null - ? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280) - : Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)), - ), - ), - ), - ), - ], + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + widget.playlistName, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); + final showContent = collapseRatio > 0.3; + + return FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + // Background with dominant color + AnimatedContainer( + duration: const Duration(milliseconds: 500), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + bgColor, + bgColor.withValues(alpha: 0.8), + colorScheme.surface, + ], + stops: const [0.0, 0.6, 1.0], + ), + ), + ), + // Cover image centered - fade out when collapsing + AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 30, + offset: const Offset(0, 15), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: widget.coverUrl != null + ? CachedNetworkImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: (coverSize * 2).toInt(), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant), + ), + ), + ), + ), + ), + ), + ], + ), + stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + ); + }, ), leading: IconButton( - icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)), + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.8), + shape: BoxShape.circle, + ), + child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + ), onPressed: () => Navigator.pop(context), ), ); } - Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { + Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), @@ -105,7 +210,7 @@ class PlaylistScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)), + Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)), const SizedBox(height: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), @@ -115,15 +220,15 @@ class PlaylistScreen extends ConsumerWidget { children: [ Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer), const SizedBox(width: 4), - Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), ], ), ), const SizedBox(height: 16), FilledButton.icon( - onPressed: () => _downloadAll(context, ref), + onPressed: () => _downloadAll(context), icon: const Icon(Icons.download), - label: Text(context.l10n.downloadAllCount(tracks.length)), + label: Text(context.l10n.downloadAllCount(widget.tracks.length)), style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), ), ], @@ -149,25 +254,25 @@ class PlaylistScreen extends ConsumerWidget { ); } - Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { + Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { return SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - final track = tracks[index]; + final track = widget.tracks[index]; return KeyedSubtree( key: ValueKey(track.id), child: _PlaylistTrackItem( track: track, - onDownload: () => _downloadTrack(context, ref, track), + onDownload: () => _downloadTrack(context, track), ), ); }, - childCount: tracks.length, + childCount: widget.tracks.length, ), ); } - void _downloadTrack(BuildContext context, WidgetRef ref, Track track) { + void _downloadTrack(BuildContext context, Track track) { final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( @@ -186,22 +291,22 @@ class PlaylistScreen extends ConsumerWidget { } } - void _downloadAll(BuildContext context, WidgetRef ref) { - if (tracks.isEmpty) return; + void _downloadAll(BuildContext context) { + if (widget.tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '${tracks.length} tracks', - artistName: playlistName, + trackName: '${widget.tracks.length} tracks', + artistName: widget.playlistName, onSelect: (quality, service) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length)))); }, ); } else { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length)))); } } } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 042db0af..b0a73f98 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -99,6 +99,17 @@ class DownloadSettingsPage extends ConsumerWidget { .read(settingsProvider.notifier) .setAskQualityBeforeDownload(value), ), + SettingsSwitchItem( + icon: Icons.audiotrack, + title: context.l10n.enableMp3Option, + subtitle: settings.enableMp3Option + ? context.l10n.enableMp3OptionSubtitleOn + : context.l10n.enableMp3OptionSubtitleOff, + value: settings.enableMp3Option, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setEnableMp3Option(value), + ), if (!settings.askQualityBeforeDownload && isBuiltInService) ...[ _QualityOption( title: context.l10n.qualityFlacLossless, @@ -123,8 +134,18 @@ class DownloadSettingsPage extends ConsumerWidget { onTap: () => ref .read(settingsProvider.notifier) .setAudioQuality('HI_RES_LOSSLESS'), - showDivider: false, + showDivider: settings.enableMp3Option, ), + if (settings.enableMp3Option) + _QualityOption( + title: context.l10n.qualityMp3, + subtitle: context.l10n.qualityMp3Subtitle, + isSelected: settings.audioQuality == 'MP3', + onTap: () => ref + .read(settingsProvider.notifier) + .setAudioQuality('MP3'), + showDivider: false, + ), ], if (!isBuiltInService) ...[ Padding( diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 67693d2a..43e7eaa4 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -28,6 +29,9 @@ class _TrackMetadataScreenState extends ConsumerState { String? _lyrics; bool _lyricsLoading = false; String? _lyricsError; + Color? _dominantColor; + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); String? _normalizeOptionalString(String? value) { if (value == null) return null; @@ -40,7 +44,42 @@ class _TrackMetadataScreenState extends ConsumerState { @override void initState() { super.initState(); + _scrollController.addListener(_onScroll); _checkFile(); + _extractDominantColor(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + final shouldShow = _scrollController.offset > 280; + if (shouldShow != _showTitleInAppBar) { + setState(() => _showTitleInAppBar = shouldShow); + } + } + + Future _extractDominantColor() async { + if (widget.item.coverUrl == null) return; + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(widget.item.coverUrl!), + maximumColorCount: 16, + ); + if (mounted) { + setState(() { + _dominantColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + }); + } + } catch (_) { + // Ignore palette extraction errors + } } Future _checkFile() async { @@ -91,21 +130,47 @@ class _TrackMetadataScreenState extends ConsumerState { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final screenWidth = MediaQuery.of(context).size.width; + final coverSize = screenWidth * 0.5; + final bgColor = _dominantColor ?? colorScheme.surface; return Scaffold( body: CustomScrollView( + controller: _scrollController, slivers: [ SliverAppBar( - expandedHeight: 280, + expandedHeight: 320, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, - flexibleSpace: FlexibleSpaceBar( - background: _buildHeaderBackground(context, colorScheme), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + backgroundColor: colorScheme.surface, // Use theme color for collapsed state + surfaceTintColor: Colors.transparent, + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + trackName, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); + final showContent = collapseRatio > 0.3; + + return FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: _buildHeaderBackground(context, colorScheme, coverSize, bgColor, showContent), + stretchModes: const [ + StretchMode.zoomBackground, + StretchMode.blurBackground, + ], + ); + }, ), leading: IconButton( icon: Container( @@ -167,74 +232,74 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme) { + Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, Color bgColor, bool showContent) { return Stack( fit: StackFit.expand, children: [ - if (item.coverUrl != null) - CachedNetworkImage( - imageUrl: item.coverUrl!, - fit: BoxFit.cover, - color: Colors.black.withValues(alpha: 0.5), - colorBlendMode: BlendMode.darken, - ), - - Container( + // Background with dominant color + AnimatedContainer( + duration: const Duration(milliseconds: 500), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.transparent, - colorScheme.surface.withValues(alpha: 0.8), + bgColor, + bgColor.withValues(alpha: 0.8), colorScheme.surface, ], - stops: const [0.0, 0.7, 1.0], + stops: const [0.0, 0.6, 1.0], ), ), ), - Center( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Hero( - tag: 'cover_${item.id}', - child: Container( - width: 140, - height: 140, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: item.coverUrl != null - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - fit: BoxFit.cover, - placeholder: (_, _) => Container( + // Cover image centered - fade out when collapsing + AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Hero( + tag: 'cover_${item.id}', + child: Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 30, + offset: const Offset(0, 15), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: item.coverUrl != null + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: (coverSize * 2).toInt(), + placeholder: (_, _) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.music_note, - size: 48, + size: 64, color: colorScheme.onSurfaceVariant, ), ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 48, - color: colorScheme.onSurfaceVariant, - ), - ), + ), ), ), ), diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index d1fdbc55..3ed2ced7 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -48,18 +48,14 @@ class FFmpegService { } /// Convert FLAC to MP3 + /// If deleteOriginal is true, deletes the FLAC file after conversion static Future convertFlacToMp3( String inputPath, { String bitrate = '320k', + bool deleteOriginal = true, }) async { - final dir = File(inputPath).parent.path; - final baseName = - inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', ''); - final outputDir = '$dir${Platform.pathSeparator}MP3'; - - await Directory(outputDir).create(recursive: true); - - final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3'; + // Convert in same folder, just change extension + final outputPath = inputPath.replaceAll('.flac', '.mp3'); final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y'; @@ -67,6 +63,12 @@ class FFmpegService { final result = await _execute(command); if (result.success) { + // Delete original FLAC if requested + if (deleteOriginal) { + try { + await File(inputPath).delete(); + } catch (_) {} + } return outputPath; } @@ -201,11 +203,147 @@ class FFmpegService { if (await tempFile.exists()) { await tempFile.delete(); } - } catch (_) {} + } catch (e) { + _log.w('Failed to cleanup temp file: $e'); + } _log.e('Metadata/Cover embed failed: ${result.output}'); return null; } + + /// Embed metadata and cover art to MP3 file using ID3v2 tags + /// Returns the file path on success, null on failure + static Future embedMetadataToMp3({ + required String mp3Path, + String? coverPath, + Map? metadata, + }) async { + final tempDir = await getTemporaryDirectory(); + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3'; + + final StringBuffer cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$mp3Path" '); + + if (coverPath != null) { + cmdBuffer.write('-i "$coverPath" '); + } + + cmdBuffer.write('-map 0:a '); + + if (coverPath != null) { + cmdBuffer.write('-map 1:0 '); + cmdBuffer.write('-c:v:0 copy '); + cmdBuffer.write('-id3v2_version 3 '); + cmdBuffer.write('-metadata:s:v title="Album cover" '); + cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); + } + + cmdBuffer.write('-c:a copy '); + + if (metadata != null) { + // Convert FLAC/Vorbis tags to ID3v2 tags for MP3 + final id3Metadata = _convertToId3Tags(metadata); + id3Metadata.forEach((key, value) { + final sanitizedValue = value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata $key="$sanitizedValue" '); + }); + } + + cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y'); + + final command = cmdBuffer.toString(); + _log.d('Executing FFmpeg MP3 embed command: $command'); + + final result = await _execute(command); + + if (result.success) { + try { + final tempFile = File(tempOutput); + final originalFile = File(mp3Path); + + if (await tempFile.exists()) { + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(mp3Path); + await tempFile.delete(); + + _log.d('MP3 metadata embedded successfully'); + return mp3Path; + } else { + _log.e('Temp MP3 output file not found: $tempOutput'); + return null; + } + + } catch (e) { + _log.e('Failed to replace MP3 file after metadata embed: $e'); + return null; + } + } + + try { + final tempFile = File(tempOutput); + if (await tempFile.exists()) { + await tempFile.delete(); + } + } catch (e) { + _log.w('Failed to cleanup temp MP3 file: $e'); + } + + _log.e('MP3 Metadata/Cover embed failed: ${result.output}'); + return null; + } + + /// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags + static Map _convertToId3Tags(Map vorbisMetadata) { + final id3Map = {}; + + for (final entry in vorbisMetadata.entries) { + final key = entry.key.toUpperCase(); + final value = entry.value; + + // Map Vorbis comments to ID3v2 frame names + switch (key) { + case 'TITLE': + id3Map['title'] = value; + break; + case 'ARTIST': + id3Map['artist'] = value; + break; + case 'ALBUM': + id3Map['album'] = value; + break; + case 'ALBUMARTIST': + id3Map['album_artist'] = value; + break; + case 'TRACKNUMBER': + case 'TRACK': + id3Map['track'] = value; + break; + case 'DISCNUMBER': + case 'DISC': + id3Map['disc'] = value; + break; + case 'DATE': + case 'YEAR': + id3Map['date'] = value; + break; + case 'ISRC': + id3Map['TSRC'] = value; // ID3v2 ISRC frame + break; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + id3Map['lyrics'] = value; + break; + default: + // Pass through other tags as-is + id3Map[key.toLowerCase()] = value; + } + } + + return id3Map; + } } /// Result of FFmpeg command execution diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index c2748dbf..962a807d 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -49,6 +49,13 @@ const _builtInServices = [ ), ]; +/// MP3 quality option (shown when enabled in settings) +const _mp3QualityOption = QualityOption( + id: 'MP3', + label: 'MP3', + description: '320kbps (converted from FLAC)', +); + /// A reusable widget for selecting download service (built-in + extensions) class DownloadServicePicker extends ConsumerStatefulWidget { final String? trackName; @@ -105,20 +112,34 @@ class _DownloadServicePickerState extends ConsumerState { /// Get quality options for the selected service List _getQualityOptions() { + final settings = ref.read(settingsProvider); final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull; if (builtIn != null) { + // Add MP3 option if enabled in settings + if (settings.enableMp3Option) { + return [...builtIn.qualityOptions, _mp3QualityOption]; + } return builtIn.qualityOptions; } final extensionState = ref.read(extensionProvider); final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull; if (ext != null && ext.qualityOptions.isNotEmpty) { + // Add MP3 option for extensions too if enabled + if (settings.enableMp3Option) { + return [...ext.qualityOptions, _mp3QualityOption]; + } return ext.qualityOptions; } - return const [ - QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), + // Default fallback options + final defaultOptions = [ + const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), ]; + if (settings.enableMp3Option) { + return [...defaultOptions, _mp3QualityOption]; + } + return defaultOptions; } @override diff --git a/pubspec.lock b/pubspec.lock index dbc3add7..a2b61026 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -653,6 +653,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" + url: "https://pub.dev" + source: hosted + version: "0.3.3+7" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b820511f..9825b60a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.1.1+60 +version: 3.1.2+61 environment: sdk: ^3.10.0 @@ -38,6 +38,7 @@ dependencies: # Material Expressive 3 / Dynamic Color dynamic_color: ^1.7.0 material_color_utilities: ^0.11.1 + palette_generator: ^0.3.3+4 # Permissions permission_handler: ^12.0.1 From 5ea454a0b037d6c47c904df19e8939da0907680a Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 12:46:23 +0700 Subject: [PATCH 18/48] fix: downloaded album navigation from recents --- CHANGELOG.md | 5 ++ lib/providers/download_queue_provider.dart | 80 +++++++++++++--------- lib/screens/downloaded_album_screen.dart | 29 ++++++-- lib/screens/home_tab.dart | 31 ++++++--- lib/screens/track_metadata_screen.dart | 27 +++++++- 5 files changed, 121 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a958fca..438dac32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,11 @@ ### Fixed +- **MP3 Quality Display in Track Metadata**: Fixed incorrect quality display for MP3 files + - MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate + - History no longer stores FLAC audio specs for converted MP3 files + - Both File Info badges and metadata grid show correct MP3 quality + - **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks - `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored - `track_provider.dart`: Added comments explaining why availability check errors are silently ignored diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index b94125b6..c846dbd0 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1671,8 +1671,12 @@ class DownloadQueueNotifier extends Notifier { if (result['success'] == true) { var filePath = result['file_path'] as String?; - if (filePath != null && filePath.startsWith('EXISTS:')) { + // Track if this was an existing file (not a new download) + // This is important to prevent converting existing FLAC files to MP3 + final wasExisting = filePath != null && filePath.startsWith('EXISTS:'); + if (wasExisting) { filePath = filePath.substring(7); // Remove "EXISTS:" prefix + _log.i('Using existing file: $filePath'); } _log.i('Download success, file: $filePath'); @@ -1806,39 +1810,48 @@ class DownloadQueueNotifier extends Notifier { } // Convert FLAC to MP3 if MP3 quality was selected + // IMPORTANT: Only convert NEW downloads, never convert existing files + // to prevent overwriting the user's existing FLAC files if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) { - _log.i('MP3 quality selected, converting FLAC to MP3...'); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.97, - ); - - try { - final mp3Path = await FFmpegService.convertFlacToMp3( - filePath, - bitrate: '320k', - deleteOriginal: true, + if (wasExisting) { + // User wanted MP3 but an existing FLAC file was found + // Do NOT convert it - that would delete their existing FLAC + _log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file'); + // Keep the existing FLAC file as-is + } else { + _log.i('MP3 quality selected, converting FLAC to MP3...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.97, ); - if (mp3Path != null) { - filePath = mp3Path; - actualQuality = 'MP3 320kbps'; - _log.i('Successfully converted to MP3: $mp3Path'); - - // Embed metadata, lyrics, and cover to the MP3 file - _log.i('Embedding metadata to MP3...'); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.99, + try { + final mp3Path = await FFmpegService.convertFlacToMp3( + filePath, + bitrate: '320k', + deleteOriginal: true, ); - await _embedMetadataToMp3(mp3Path, trackToDownload); - } else { - _log.w('MP3 conversion failed, keeping FLAC file'); + + if (mp3Path != null) { + filePath = mp3Path; + actualQuality = 'MP3 320kbps'; + _log.i('Successfully converted to MP3: $mp3Path'); + + // Embed metadata, lyrics, and cover to the MP3 file + _log.i('Embedding metadata to MP3...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.99, + ); + await _embedMetadataToMp3(mp3Path, trackToDownload); + } else { + _log.w('MP3 conversion failed, keeping FLAC file'); + } + } catch (e) { + _log.e('MP3 conversion error: $e, keeping FLAC file'); } - } catch (e) { - _log.e('MP3 conversion error: $e, keeping FLAC file'); } } @@ -1881,6 +1894,11 @@ class DownloadQueueNotifier extends Notifier { ? normalizedAlbumArtist : null; + // For MP3 files, don't save FLAC bitDepth/sampleRate - they're not applicable + final isMp3 = filePath.endsWith('.mp3'); + final historyBitDepth = isMp3 ? null : backendBitDepth; + final historySampleRate = isMp3 ? null : backendSampleRate; + ref .read(downloadHistoryProvider.notifier) .addToHistory( @@ -1915,8 +1933,8 @@ class DownloadQueueNotifier extends Notifier { ? backendYear : trackToDownload.releaseDate, quality: actualQuality, - bitDepth: backendBitDepth, - sampleRate: backendSampleRate, + bitDepth: historyBitDepth, + sampleRate: historySampleRate, ), ); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 6031687c..7536911b 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -56,7 +56,13 @@ class _DownloadedAlbumScreenState extends ConsumerState { } Future _extractDominantColor() async { - if (widget.coverUrl == null) return; + if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return; + + // Only use network images for palette extraction + final isNetworkUrl = widget.coverUrl!.startsWith('http://') || + widget.coverUrl!.startsWith('https://'); + if (!isNetworkUrl) return; + try { final paletteGenerator = await PaletteGenerator.fromImageProvider( CachedNetworkImageProvider(widget.coverUrl!), @@ -77,7 +83,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { /// Get tracks for this album from history provider (reactive) List _getAlbumTracks(List allItems) { return allItems.where((item) { - final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + // Use albumArtist if available and not empty, otherwise artistName + final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) + ? item.albumArtist! + : item.artistName; + final itemKey = '${item.albumName}|$itemArtist'; final albumKey = '${widget.albumName}|${widget.artistName}'; return itemKey == albumKey; }).toList() @@ -229,11 +239,16 @@ class _DownloadedAlbumScreenState extends ConsumerState { final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final tracks = _getAlbumTracks(allHistoryItems); - if (tracks.length < 2) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) Navigator.pop(context); - }); - return const SizedBox.shrink(); + // Show empty state if no tracks found + if (tracks.isEmpty) { + return Scaffold( + appBar: AppBar( + title: Text(widget.albumName), + ), + body: Center( + child: Text('No tracks found for this album'), + ), + ); } final validIds = tracks.map((t) => t.id).toSet(); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 06e7c27c..fd02357c 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -654,8 +654,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // Group download history by album to avoid flooding recents with individual tracks final albumMap = {}; for (final h in historyItems) { - // Use album name + artist as unique key - final albumKey = '${h.albumName}|${h.albumArtist ?? h.artistName}'; + // Use album name + artist as unique key (handle empty albumArtist) + final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) + ? h.albumArtist! + : h.artistName; + final albumKey = '${h.albumName}|$artistForKey'; // Keep the most recent download for each album if (!albumMap.containsKey(albumKey) || h.downloadedAt.isAfter(albumMap[albumKey]!.downloadedAt)) { @@ -664,15 +667,21 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } // Convert grouped albums to RecentAccessItem with album type - final downloadItems = albumMap.values.take(10).map((h) => RecentAccessItem( - id: '${h.albumName}|${h.albumArtist ?? h.artistName}', // Use album key as ID - name: h.albumName, - subtitle: h.albumArtist ?? h.artistName, - imageUrl: h.coverUrl, - type: RecentAccessType.album, - accessedAt: h.downloadedAt, - providerId: 'download', - )).toList(); + final downloadItems = albumMap.values.take(10).map((h) { + // Use albumArtist if available and not empty, otherwise artistName + final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) + ? h.albumArtist! + : h.artistName; + return RecentAccessItem( + id: '${h.albumName}|$artistForKey', // Use album key as ID + name: h.albumName, + subtitle: artistForKey, + imageUrl: h.coverUrl, + type: RecentAccessType.album, + accessedAt: h.downloadedAt, + providerId: 'download', + ); + }).toList(); final allItems = [...items, ...downloadItems]; allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 43e7eaa4..52b25ea6 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -490,8 +490,14 @@ class _TrackMetadataScreenState extends ConsumerState { } Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { + // Determine audio quality string based on file type String? audioQualityStr; - if (bitDepth != null && sampleRate != null) { + final fileName = item.filePath.split('/').last; + final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : ''; + + if (fileExt == 'MP3') { + audioQualityStr = '320kbps'; + } else if (bitDepth != null && sampleRate != null) { final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1); audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz'; } @@ -643,7 +649,24 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - if (bitDepth != null && sampleRate != null) + // Show 320kbps for MP3, bit depth/sample rate for FLAC + if (fileExtension == 'MP3') + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '320kbps', + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ) + else if (bitDepth != null && sampleRate != null) Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( From bc120ffa76c588720149f082b56ad08323ac8787 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 12:52:25 +0700 Subject: [PATCH 19/48] feat: allow hiding downloads from recents without deleting files - Add hiddenDownloadIds set to RecentAccessState - X button on download items hides from recents (not delete file) - Hidden IDs persisted in SharedPreferences - Clear All also clears hidden downloads list - Single track shows as Track, 2+ tracks shows as Album in recents --- lib/providers/recent_access_provider.dart | 46 +++++++++++-- lib/screens/home_tab.dart | 82 ++++++++++++++++------- 2 files changed, 98 insertions(+), 30 deletions(-) diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index aad77455..e1cda16a 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; const _recentAccessKey = 'recent_access_history'; +const _hiddenDownloadsKey = 'hidden_downloads_in_recents'; const _maxRecentItems = 20; /// Types of items that can be accessed @@ -75,19 +76,23 @@ class RecentAccessItem { /// State for recent access history class RecentAccessState { final List items; + final Set hiddenDownloadIds; // IDs of downloads hidden from recents final bool isLoaded; const RecentAccessState({ this.items = const [], + this.hiddenDownloadIds = const {}, this.isLoaded = false, }); RecentAccessState copyWith({ List? items, + Set? hiddenDownloadIds, bool? isLoaded, }) { return RecentAccessState( items: items ?? this.items, + hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds, isLoaded: isLoaded ?? this.isLoaded, ); } @@ -104,19 +109,27 @@ class RecentAccessNotifier extends Notifier { Future _loadHistory() async { final prefs = await SharedPreferences.getInstance(); final json = prefs.getString(_recentAccessKey); + final hiddenJson = prefs.getStringList(_hiddenDownloadsKey); + + List items = []; + Set hiddenIds = {}; + if (json != null) { try { final List decoded = jsonDecode(json); - final items = decoded + items = decoded .map((e) => RecentAccessItem.fromJson(e as Map)) .toList(); - state = state.copyWith(items: items, isLoaded: true); } catch (e) { - state = state.copyWith(isLoaded: true); + // Ignore parse errors } - } else { - state = state.copyWith(isLoaded: true); } + + if (hiddenJson != null) { + hiddenIds = hiddenJson.toSet(); + } + + state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true); } Future _saveHistory() async { @@ -125,6 +138,11 @@ class RecentAccessNotifier extends Notifier { await prefs.setString(_recentAccessKey, json); } + Future _saveHiddenDownloads() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList()); + } + /// Record an access to an artist void recordArtistAccess({ required String id, @@ -229,11 +247,29 @@ class RecentAccessNotifier extends Notifier { _saveHistory(); } + /// Hide a download item from recents (without deleting the actual download) + void hideDownloadFromRecents(String downloadId) { + final updatedHidden = {...state.hiddenDownloadIds, downloadId}; + state = state.copyWith(hiddenDownloadIds: updatedHidden); + _saveHiddenDownloads(); + } + + /// Check if a download is hidden from recents + bool isDownloadHidden(String downloadId) { + return state.hiddenDownloadIds.contains(downloadId); + } + /// Clear all history void clearHistory() { state = state.copyWith(items: []); _saveHistory(); } + + /// Clear hidden downloads (show all again) + void clearHiddenDownloads() { + state = state.copyWith(hiddenDownloadIds: {}); + _saveHiddenDownloads(); + } } /// Provider instance diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index fd02357c..72b76ce2 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -651,39 +651,64 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Widget _buildRecentAccess(List items, ColorScheme colorScheme) { final historyItems = ref.read(downloadHistoryProvider).items; - // Group download history by album to avoid flooding recents with individual tracks - final albumMap = {}; + // Group download history by album + final albumGroups = >{}; for (final h in historyItems) { - // Use album name + artist as unique key (handle empty albumArtist) final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) ? h.albumArtist! : h.artistName; final albumKey = '${h.albumName}|$artistForKey'; - // Keep the most recent download for each album - if (!albumMap.containsKey(albumKey) || - h.downloadedAt.isAfter(albumMap[albumKey]!.downloadedAt)) { - albumMap[albumKey] = h; + albumGroups.putIfAbsent(albumKey, () => []).add(h); + } + + // Convert to RecentAccessItem based on track count: + // - 1 track: show as individual Track + // - 2+ tracks: show as Album + final downloadItems = []; + for (final entry in albumGroups.entries) { + final tracks = entry.value; + final mostRecent = tracks.reduce((a, b) => + a.downloadedAt.isAfter(b.downloadedAt) ? a : b); + final artistForKey = (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) + ? mostRecent.albumArtist! + : mostRecent.artistName; + + if (tracks.length == 1) { + // Single track - show as Track + downloadItems.add(RecentAccessItem( + id: mostRecent.spotifyId ?? mostRecent.id, + name: mostRecent.trackName, + subtitle: mostRecent.artistName, + imageUrl: mostRecent.coverUrl, + type: RecentAccessType.track, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + )); + } else { + // Multiple tracks - show as Album + downloadItems.add(RecentAccessItem( + id: '${mostRecent.albumName}|$artistForKey', + name: mostRecent.albumName, + subtitle: artistForKey, + imageUrl: mostRecent.coverUrl, + type: RecentAccessType.album, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + )); } } - // Convert grouped albums to RecentAccessItem with album type - final downloadItems = albumMap.values.take(10).map((h) { - // Use albumArtist if available and not empty, otherwise artistName - final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) - ? h.albumArtist! - : h.artistName; - return RecentAccessItem( - id: '${h.albumName}|$artistForKey', // Use album key as ID - name: h.albumName, - subtitle: artistForKey, - imageUrl: h.coverUrl, - type: RecentAccessType.album, - accessedAt: h.downloadedAt, - providerId: 'download', - ); - }).toList(); + // Sort by most recent and take top 10 + downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - final allItems = [...items, ...downloadItems]; + // Filter out hidden downloads + final hiddenIds = ref.read(recentAccessProvider).hiddenDownloadIds; + final visibleDownloads = downloadItems + .where((item) => !hiddenIds.contains(item.id)) + .take(10) + .toList(); + + final allItems = [...items, ...visibleDownloads]; allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); final seen = {}; @@ -711,6 +736,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient TextButton( onPressed: () { ref.read(recentAccessProvider.notifier).clearHistory(); + ref.read(recentAccessProvider.notifier).clearHiddenDownloads(); }, child: Text( context.l10n.dialogClearAll, @@ -804,7 +830,13 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient IconButton( icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant), onPressed: () { - ref.read(recentAccessProvider.notifier).removeItem(item); + if (item.providerId == 'download') { + // For download items, hide from recents without deleting the file + ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); + } else { + // For other items, remove from recent history + ref.read(recentAccessProvider.notifier).removeItem(item); + } }, ), ], From 6b1958bfd0ec84f782c5f4f72d62980493fd7cea Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 12:56:36 +0700 Subject: [PATCH 20/48] feat: show 'Show All Downloads' button when recents is empty - Button appears when all items are cleared/hidden - Clicking resets hidden downloads list - Clear All button only shows when there are items - Empty state with visibility_off icon --- lib/screens/home_tab.dart | 58 ++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 72b76ce2..9a91ba05 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -719,6 +719,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return true; }).take(10).toList(); + // Check if there are hidden downloads + final hasHiddenDownloads = hiddenIds.isNotEmpty; + return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Column( @@ -733,20 +736,55 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient color: colorScheme.onSurfaceVariant, ), ), - TextButton( - onPressed: () { - ref.read(recentAccessProvider.notifier).clearHistory(); - ref.read(recentAccessProvider.notifier).clearHiddenDownloads(); - }, - child: Text( - context.l10n.dialogClearAll, - style: TextStyle(color: colorScheme.primary, fontSize: 12), + if (uniqueItems.isNotEmpty) + TextButton( + onPressed: () { + // Hide all visible download items + for (final item in uniqueItems) { + if (item.providerId == 'download') { + ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); + } + } + // Clear non-download recent history + ref.read(recentAccessProvider.notifier).clearHistory(); + }, + child: Text( + context.l10n.dialogClearAll, + style: TextStyle(color: colorScheme.primary, fontSize: 12), + ), ), - ), ], ), const SizedBox(height: 8), - ...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)), + if (uniqueItems.isEmpty && hasHiddenDownloads) + // Show "Show All" button when recents is empty but there are hidden downloads + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Icon(Icons.visibility_off, size: 48, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), + const SizedBox(height: 12), + Text( + 'No recent items', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () { + ref.read(recentAccessProvider.notifier).clearHiddenDownloads(); + }, + icon: const Icon(Icons.visibility, size: 18), + label: const Text('Show All Downloads'), + ), + ], + ), + ), + ) + else + ...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)), ], ), ); From f814408702dbee2dd2eece543a28350ae3f4d3c8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 12:59:10 +0700 Subject: [PATCH 21/48] style: reduce AppBar title font size to 16px for long titles --- lib/screens/album_screen.dart | 1 + lib/screens/artist_screen.dart | 8 +++++++- lib/screens/downloaded_album_screen.dart | 1 + lib/screens/playlist_screen.dart | 1 + lib/screens/track_metadata_screen.dart | 1 + 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index aad638f5..1c701a52 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -228,6 +228,7 @@ class _AlbumScreenState extends ConsumerState { style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, + fontSize: 16, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 23b21cc1..b0ea0130 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -341,7 +341,13 @@ return SliverAppBar( opacity: _showTitleInAppBar ? 1.0 : 0.0, child: Text( widget.artistName, - style: TextStyle(color: colorScheme.onSurface), + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), flexibleSpace: FlexibleSpaceBar( diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 7536911b..6ccec1db 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -313,6 +313,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, + fontSize: 16, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 3ff948f6..380dc02d 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -109,6 +109,7 @@ class _PlaylistScreenState extends ConsumerState { style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, + fontSize: 16, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 52b25ea6..1f9134de 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -152,6 +152,7 @@ class _TrackMetadataScreenState extends ConsumerState { style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, + fontSize: 16, ), maxLines: 1, overflow: TextOverflow.ellipsis, From bf2fc7702b9657b4be966d3c1d11112638a0aca5 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 13:09:37 +0700 Subject: [PATCH 22/48] chore: remove debug print statements from recent_access_provider --- lib/providers/recent_access_provider.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index e1cda16a..1671eda4 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -218,9 +218,6 @@ class RecentAccessNotifier extends Notifier { } void _recordAccess(RecentAccessItem item) { - // ignore: avoid_print - print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})'); - final updatedItems = state.items .where((e) => e.uniqueKey != item.uniqueKey) .toList(); @@ -233,9 +230,6 @@ class RecentAccessNotifier extends Notifier { state = state.copyWith(items: updatedItems); _saveHistory(); - - // ignore: avoid_print - print('[RecentAccess] Total items now: ${updatedItems.length}'); } /// Remove a specific item from history From a1647a41ff665aa526e1a9c91281b548227ea5f3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 13:12:05 +0700 Subject: [PATCH 23/48] fix: use ref.watch for hiddenDownloadIds reactivity Show All Downloads button now updates immediately without restart --- lib/screens/home_tab.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 9a91ba05..04a6ff88 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -701,8 +701,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // Sort by most recent and take top 10 downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - // Filter out hidden downloads - final hiddenIds = ref.read(recentAccessProvider).hiddenDownloadIds; + // Filter out hidden downloads (use ref.watch for reactivity) + final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds)); final visibleDownloads = downloadItems .where((item) => !hiddenIds.contains(item.id)) .take(10) From c2599981d6d8a01d106f5f560762ef8bf629cd30 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 13:12:45 +0700 Subject: [PATCH 24/48] fix: Clear All now hides ALL downloads, not just visible 10 Previously only hid uniqueItems (max 10 visible), now hides all downloadItems --- lib/screens/home_tab.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 04a6ff88..e68c61de 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -739,11 +739,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (uniqueItems.isNotEmpty) TextButton( onPressed: () { - // Hide all visible download items - for (final item in uniqueItems) { - if (item.providerId == 'download') { - ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); - } + // Hide ALL download items (not just visible ones) + for (final item in downloadItems) { + ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); } // Clear non-download recent history ref.read(recentAccessProvider.notifier).clearHistory(); From 42d15db4caefe122475f6008615b050764a2f559 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 18 Jan 2026 13:17:52 +0700 Subject: [PATCH 25/48] fix: show 'Artist' label for artist items instead of 'Album' Fixed fallback subtitle in _CollectionItemWidget for artist search results --- lib/screens/home_tab.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index e68c61de..e9b501e6 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1742,7 +1742,9 @@ class _CollectionItemWidget extends StatelessWidget { ), const SizedBox(height: 2), Text( - item.artistName.isNotEmpty ? item.artistName : (isPlaylist ? 'Playlist' : 'Album'), + item.artistName.isNotEmpty + ? item.artistName + : (isPlaylist ? 'Playlist' : (isArtist ? 'Artist' : 'Album')), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 1, overflow: TextOverflow.ellipsis, From e7077781e67c1706b3e3d36b3331d41175e94b18 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 00:48:11 +0700 Subject: [PATCH 26/48] feat: add genre and label metadata to FLAC downloads - Fetch genre and label from Deezer album API before download - Add GENRE, ORGANIZATION (label), and COPYRIGHT tags to FLAC files - Update Go Metadata struct with new fields - Add GetDeezerExtendedMetadata export function for Flutter - Register platform channel handlers for Android and iOS - Pass genre/label through download flow to all services (Tidal/Qobuz/Amazon) --- CHANGELOG.md | 6 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 15 +++ go_backend/amazon.go | 3 + go_backend/deezer.go | 116 ++++++++++++++++-- go_backend/exports.go | 52 ++++++++ go_backend/metadata.go | 27 ++++ go_backend/qobuz.go | 3 + go_backend/spotify.go | 3 + go_backend/tidal.go | 3 + ios/Runner/AppDelegate.swift | 15 +++ lib/providers/download_queue_provider.dart | 31 +++++ lib/services/platform_bridge.dart | 41 +++++++ 12 files changed, 306 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 438dac32..0569a894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Added +- **Genre & Label Metadata**: Downloaded tracks now include genre and record label information + - Fetches genre and label from Deezer album API for each track + - Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files + - Works automatically when Deezer track ID is available (via ISRC matching) + - Supports all download services (Tidal, Qobuz, Amazon) + - **MP3 Quality Option**: Optional MP3 download format with FLAC-to-MP3 conversion - New "Enable MP3 Option" toggle in Settings > Download > Audio Quality - When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index fb5ec321..625bfd66 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -284,6 +284,13 @@ class MainActivity: FlutterActivity() { } result.success(response) } + "getDeezerExtendedMetadata" -> { + val trackId = call.argument("track_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getDeezerExtendedMetadata(trackId) + } + result.success(response) + } "convertSpotifyToDeezer" -> { val resourceType = call.argument("resource_type") ?: "" val spotifyId = call.argument("spotify_id") ?: "" @@ -438,6 +445,14 @@ class MainActivity: FlutterActivity() { } result.success(null) } + "invokeExtensionAction" -> { + val extensionId = call.argument("extension_id") ?: "" + val actionName = call.argument("action") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.invokeExtensionActionJSON(extensionId, actionName) + } + result.success(response) + } "searchTracksWithExtensions" -> { val query = call.argument("query") ?: "" val limit = call.argument("limit") ?: 20 diff --git a/go_backend/amazon.go b/go_backend/amazon.go index b6bb4c40..f5e6dc69 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -564,6 +564,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { TotalTracks: req.TotalTracks, DiscNumber: actualDiscNum, ISRC: req.ISRC, + Genre: req.Genre, // From Deezer album metadata + Label: req.Label, // From Deezer album metadata + Copyright: req.Copyright, // From Deezer album metadata } // Use cover data from parallel fetch diff --git a/go_backend/deezer.go b/go_backend/deezer.go index a3fcedd5..03ef7fdc 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -132,16 +132,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { } } +type deezerGenre struct { + ID int `json:"id"` + Name string `json:"name"` +} + type deezerAlbumFull struct { - ID int64 `json:"id"` - Title string `json:"title"` - Cover string `json:"cover"` - CoverMedium string `json:"cover_medium"` - CoverBig string `json:"cover_big"` - CoverXL string `json:"cover_xl"` - ReleaseDate string `json:"release_date"` - NbTracks int `json:"nb_tracks"` - RecordType string `json:"record_type"` // album, single, ep, compile + ID int64 `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + CoverMedium string `json:"cover_medium"` + CoverBig string `json:"cover_big"` + CoverXL string `json:"cover_xl"` + ReleaseDate string `json:"release_date"` + NbTracks int `json:"nb_tracks"` + RecordType string `json:"record_type"` // album, single, ep, compile + Label string `json:"label"` // Record label name + Genres struct { + Data []deezerGenre `json:"data"` + } `json:"genres"` Artist deezerArtist `json:"artist"` Contributors []deezerArtist `json:"contributors"` Tracks struct { @@ -310,12 +319,23 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp artistName = strings.Join(names, ", ") } + // Extract genres as comma-separated string + var genres []string + for _, g := range album.Genres.Data { + if g.Name != "" { + genres = append(genres, g.Name) + } + } + genreStr := strings.Join(genres, ", ") + info := AlbumInfoMetadata{ TotalTracks: album.NbTracks, Name: album.Title, ReleaseDate: album.ReleaseDate, Artists: artistName, Images: albumImage, + Genre: genreStr, // From Deezer album + Label: album.Label, // From Deezer album } // Fetch ISRCs in parallel @@ -677,6 +697,84 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { return album.Cover } +// AlbumExtendedMetadata contains genre and label information from an album +type AlbumExtendedMetadata struct { + Genre string // Comma-separated list of genres + Label string // Record label name +} + +// GetAlbumExtendedMetadata fetches genre and label from a Deezer album +// Uses the album ID from a track to fetch extended metadata +func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { + if albumID == "" { + return nil, fmt.Errorf("empty album ID") + } + + // Check cache first + cacheKey := fmt.Sprintf("album_meta:%s", albumID) + c.cacheMu.RLock() + if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { + c.cacheMu.RUnlock() + return entry.data.(*AlbumExtendedMetadata), nil + } + c.cacheMu.RUnlock() + + albumURL := fmt.Sprintf(deezerAlbumURL, albumID) + + var album deezerAlbumFull + if err := c.getJSON(ctx, albumURL, &album); err != nil { + return nil, fmt.Errorf("failed to fetch album: %w", err) + } + + // Extract genres as comma-separated string + var genres []string + for _, g := range album.Genres.Data { + if g.Name != "" { + genres = append(genres, g.Name) + } + } + + result := &AlbumExtendedMetadata{ + Genre: strings.Join(genres, ", "), + Label: album.Label, + } + + // Cache the result + c.cacheMu.Lock() + c.searchCache[cacheKey] = &cacheEntry{ + data: result, + expiresAt: time.Now().Add(deezerCacheTTL), + } + c.cacheMu.Unlock() + + GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label) + + return result, nil +} + +// GetTrackAlbumID fetches the album ID for a Deezer track +func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) { + trackURL := fmt.Sprintf(deezerTrackURL, trackID) + + var track deezerTrack + if err := c.getJSON(ctx, trackURL, &track); err != nil { + return "", err + } + + return fmt.Sprintf("%d", track.Album.ID), nil +} + +// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID +// This is a convenience function that first gets the album ID, then fetches album metadata +func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) { + albumID, err := c.GetTrackAlbumID(ctx, trackID) + if err != nil { + return nil, fmt.Errorf("failed to get album ID: %w", err) + } + + return c.GetAlbumExtendedMetadata(ctx, albumID) +} + func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { diff --git a/go_backend/exports.go b/go_backend/exports.go index 3206b494..6d65ef0c 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -153,6 +153,10 @@ type DownloadRequest struct { ItemID string `json:"item_id"` // Unique ID for progress tracking DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) Source string `json:"source"` // Extension ID that provided this track (prioritize this extension) + // Extended metadata from Deezer for FLAC tagging + Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated + Label string `json:"label,omitempty"` // Record label name + Copyright string `json:"copyright,omitempty"` // Copyright information // Enriched IDs from Odesli/song.link - used to skip search and directly fetch TidalID string `json:"tidal_id,omitempty"` QobuzID string `json:"qobuz_id,omitempty"` @@ -837,6 +841,37 @@ func ParseDeezerURLExport(url string) (string, error) { return string(jsonBytes), nil } +// GetDeezerExtendedMetadata fetches genre and label from Deezer album +// trackID: Deezer track ID (will look up album ID from track) +// Returns JSON with genre, label fields +func GetDeezerExtendedMetadata(trackID string) (string, error) { + if trackID == "" { + return "", fmt.Errorf("empty track ID") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client := GetDeezerClient() + metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID) + if err != nil { + GoLog("[Deezer] Failed to get extended metadata: %v\n", err) + return "", err + } + + result := map[string]string{ + "genre": metadata.Genre, + "label": metadata.Label, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + // SearchDeezerByISRC searches for a track by ISRC on Deezer func SearchDeezerByISRC(isrc string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -1290,6 +1325,23 @@ func CleanupExtensions() { manager.UnloadAllExtensions() } +// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler) +// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.) +func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) { + manager := GetExtensionManager() + result, err := manager.InvokeAction(extensionID, actionName) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + // ==================== EXTENSION AUTH API ==================== // GetExtensionPendingAuthJSON returns pending auth request for an extension diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 25f09dac..fd390704 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -24,6 +24,9 @@ type Metadata struct { ISRC string Description string Lyrics string + Genre string // Music genre (e.g., "Rock", "Pop", "Electronic") + Label string // Record label (ORGANIZATION tag in Vorbis) + Copyright string // Copyright information } // EmbedMetadata embeds metadata into a FLAC file @@ -82,6 +85,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) } + if metadata.Genre != "" { + setComment(cmt, "GENRE", metadata.Genre) + } + + if metadata.Label != "" { + setComment(cmt, "ORGANIZATION", metadata.Label) + } + + if metadata.Copyright != "" { + setComment(cmt, "COPYRIGHT", metadata.Copyright) + } + cmtBlock := cmt.Marshal() if cmtIdx >= 0 { f.Meta[cmtIdx] = &cmtBlock @@ -180,6 +195,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) } + if metadata.Genre != "" { + setComment(cmt, "GENRE", metadata.Genre) + } + + if metadata.Label != "" { + setComment(cmt, "ORGANIZATION", metadata.Label) + } + + if metadata.Copyright != "" { + setComment(cmt, "COPYRIGHT", metadata.Copyright) + } + cmtBlock := cmt.Marshal() if cmtIdx >= 0 { f.Meta[cmtIdx] = &cmtBlock diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 2719b909..5165c127 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1120,6 +1120,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { TotalTracks: req.TotalTracks, DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result ISRC: track.ISRC, + Genre: req.Genre, // From Deezer album metadata + Label: req.Label, // From Deezer album metadata + Copyright: req.Copyright, // From Deezer album metadata } var coverData []byte diff --git a/go_backend/spotify.go b/go_backend/spotify.go index b88d275f..22f4cf99 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -182,6 +182,9 @@ type AlbumInfoMetadata struct { ReleaseDate string `json:"release_date"` Artists string `json:"artists"` Images string `json:"images"` + Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated + Label string `json:"label,omitempty"` // Record label name + Copyright string `json:"copyright,omitempty"` // Copyright information } // AlbumResponsePayload is the response for album requests diff --git a/go_backend/tidal.go b/go_backend/tidal.go index d537a954..45a92ca2 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1716,6 +1716,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { TotalTracks: req.TotalTracks, DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal ISRC: track.ISRC, // Use actual ISRC from Tidal + Genre: req.Genre, // From Deezer album metadata + Label: req.Label, // From Deezer album metadata + Copyright: req.Copyright, // From Deezer album metadata } var coverData []byte diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 789a950d..c6a373d7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -227,6 +227,13 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "getDeezerExtendedMetadata": + let args = call.arguments as! [String: Any] + let trackId = args["track_id"] as! String + let response = GobackendGetDeezerExtendedMetadata(trackId, &error) + if let error = error { throw error } + return response + case "convertSpotifyToDeezer": let args = call.arguments as! [String: Any] let resourceType = args["resource_type"] as! String @@ -375,6 +382,14 @@ import Gobackend // Import Go framework if let error = error { throw error } return nil + case "invokeExtensionAction": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let actionName = args["action"] as! String + let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error) + if let error = error { throw error } + return response + case "searchTracksWithExtensions": let args = call.arguments as! [String: Any] let query = args["query"] as! String diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index c846dbd0..3a3dc082 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1568,6 +1568,35 @@ class DownloadQueueNotifier extends Notifier { final quality = item.qualityOverride ?? state.audioQuality; + // Fetch extended metadata (genre, label) from Deezer if available + String? genre; + String? label; + + // Try to get Deezer track ID from various sources + String? deezerTrackId = trackToDownload.deezerId; + if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { + deezerTrackId = trackToDownload.id.split(':')[1]; + } + if (deezerTrackId == null && trackToDownload.availability?.deezerId != null) { + deezerTrackId = trackToDownload.availability!.deezerId; + } + + if (deezerTrackId != null && deezerTrackId.isNotEmpty) { + try { + final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId); + if (extendedMetadata != null) { + genre = extendedMetadata['genre']; + label = extendedMetadata['label']; + if (genre != null && genre.isNotEmpty) { + _log.d('Extended metadata - Genre: $genre, Label: $label'); + } + } + } catch (e) { + _log.w('Failed to fetch extended metadata from Deezer: $e'); + // Continue without extended metadata + } + } + Map result; final extensionState = ref.read(extensionProvider); @@ -1622,6 +1651,8 @@ class DownloadQueueNotifier extends Notifier { itemId: item.id, // Pass item ID for progress tracking durationMs: trackToDownload.duration, // Duration in ms for verification + genre: genre, + label: label, ); } else { result = await PlatformBridge.downloadTrack( diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 3df93bfc..44ef3939 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -129,6 +129,10 @@ class PlatformBridge { String preferredService = 'tidal', String? itemId, int durationMs = 0, + // Extended metadata for FLAC tagging + String? genre, + String? label, + String? copyright, }) async { _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); final request = jsonEncode({ @@ -151,6 +155,10 @@ class PlatformBridge { 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', 'duration_ms': durationMs, + // Extended metadata + 'genre': genre ?? '', + 'label': label ?? '', + 'copyright': copyright ?? '', }); final result = await _channel.invokeMethod('downloadWithFallback', request); @@ -411,6 +419,25 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + /// Get extended metadata (genre, label) from Deezer using track ID + /// Returns {"genre": "...", "label": "..."} or null if not found + static Future?> getDeezerExtendedMetadata(String trackId) async { + try { + final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { + 'track_id': trackId, + }); + if (result == null) return null; + final data = jsonDecode(result as String) as Map; + return { + 'genre': data['genre'] as String? ?? '', + 'label': data['label'] as String? ?? '', + }; + } catch (e) { + _log.w('Failed to get Deezer extended metadata for $trackId: $e'); + return null; + } + } + /// Convert Spotify track to Deezer and get metadata (for rate limit fallback) static Future> convertSpotifyToDeezer(String resourceType, String spotifyId) async { final result = await _channel.invokeMethod('convertSpotifyToDeezer', { @@ -583,6 +610,20 @@ class PlatformBridge { }); } + /// Invoke an action on an extension (e.g., button click handler like "startLogin") + /// Returns the result from the JS function + static Future> invokeExtensionAction(String extensionId, String actionName) async { + _log.d('invokeExtensionAction: $extensionId.$actionName'); + final result = await _channel.invokeMethod('invokeExtensionAction', { + 'extension_id': extensionId, + 'action': actionName, + }); + if (result == null || (result as String).isEmpty) { + return {'success': true}; + } + return jsonDecode(result) as Map; + } + /// Search tracks using extension providers static Future>> searchTracksWithExtensions(String query, {int limit = 20}) async { _log.d('searchTracksWithExtensions: "$query"'); From 5f39a3d52f62a1c59ccb72302841f27a321663a6 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 00:49:51 +0700 Subject: [PATCH 27/48] fix: use CollapseMode.none for smoother header animation --- lib/screens/album_screen.dart | 2 +- lib/screens/artist_screen.dart | 1 + lib/screens/downloaded_album_screen.dart | 2 +- lib/screens/playlist_screen.dart | 2 +- lib/screens/track_metadata_screen.dart | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 1c701a52..0e9125a3 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -240,7 +240,7 @@ class _AlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.pin, + collapseMode: CollapseMode.none, background: Stack( fit: StackFit.expand, children: [ diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index b0ea0130..4e121f6a 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -351,6 +351,7 @@ return SliverAppBar( ), ), flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.none, background: Stack( fit: StackFit.expand, children: [ diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 6ccec1db..477e4abc 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -325,7 +325,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.pin, + collapseMode: CollapseMode.none, background: Stack( fit: StackFit.expand, children: [ diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 380dc02d..aa76f696 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -121,7 +121,7 @@ class _PlaylistScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.pin, + collapseMode: CollapseMode.none, background: Stack( fit: StackFit.expand, children: [ diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 1f9134de..603c1939 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -164,7 +164,7 @@ class _TrackMetadataScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.pin, + collapseMode: CollapseMode.none, background: _buildHeaderBackground(context, colorScheme, coverSize, bgColor, showContent), stretchModes: const [ StretchMode.zoomBackground, From 595bfb271167703e0efe47776297a9a70e2e8167 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 00:50:30 +0700 Subject: [PATCH 28/48] feat: add button setting type for extension actions - Add SettingTypeButton for action buttons in extension settings - Add Action field to ExtensionSetting for JS function name - Update extension detail page UI to render button settings - Add InvokeAction method to execute button actions --- go_backend/extension_manager.go | 57 +++++++ go_backend/extension_manifest.go | 11 ++ lib/providers/extension_provider.dart | 5 +- .../settings/extension_detail_page.dart | 149 +++++++++++++++--- 4 files changed, 201 insertions(+), 21 deletions(-) diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 857a8dae..a601c1a4 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -959,3 +959,60 @@ func (m *ExtensionManager) UnloadAllExtensions() { GoLog("[Extension] All extensions unloaded\n") } + +// InvokeAction calls a custom action function on an extension (e.g., for button settings) +// The function is called as extension.() and can return a result +func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return nil, fmt.Errorf("extension not found: %s", extensionID) + } + + if ext.VM == nil { + return nil, fmt.Errorf("extension VM not initialized") + } + + if !ext.Enabled { + return nil, fmt.Errorf("extension is disabled") + } + + // Call the action function on the extension object + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { + try { + var result = extension.%s(); + if (result && typeof result.then === 'function') { + // Handle promise - return pending status + return { success: true, pending: true, message: 'Action started' }; + } + return { success: true, result: result }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: false, error: 'Action function not found: %s' }; + })() + `, actionName, actionName, actionName) + + result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout) + if err != nil { + GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err) + return nil, fmt.Errorf("action failed: %v", err) + } + + if result == nil || goja.IsUndefined(result) { + return map[string]interface{}{"success": true}, nil + } + + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap) + return resultMap, nil + } + + return map[string]interface{}{"success": true, "result": exported}, nil +} diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 7a7a37f3..0a4fce24 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -23,6 +23,7 @@ const ( SettingTypeNumber SettingType = "number" SettingTypeBool SettingType = "boolean" SettingTypeSelect SettingType = "select" + SettingTypeButton SettingType = "button" // Action button that calls a JS function ) // ExtensionPermissions defines what resources an extension can access @@ -42,6 +43,7 @@ type ExtensionSetting struct { Secret bool `json:"secret,omitempty"` Default interface{} `json:"default,omitempty"` Options []string `json:"options,omitempty"` // For select type + Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin") } // QualityOption represents a quality option for download providers @@ -204,6 +206,7 @@ func (m *ExtensionManifest) Validate() error { SettingTypeNumber: true, SettingTypeBool: true, SettingTypeSelect: true, + SettingTypeButton: true, } if !validTypes[setting.Type] { return &ManifestValidationError{ @@ -219,6 +222,14 @@ func (m *ExtensionManifest) Validate() error { Message: "select type requires options", } } + + // Button type requires action + if setting.Type == SettingTypeButton && setting.Action == "" { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].action", i), + Message: "button type requires action (JS function name)", + } + } } return nil diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 7086051b..3eb6f444 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -355,11 +355,12 @@ class QualitySpecificSetting { class ExtensionSetting { final String key; final String label; - final String type; // 'string', 'number', 'boolean', 'select' + final String type; // 'string', 'number', 'boolean', 'select', 'button' final dynamic defaultValue; final String? description; final List? options; // For select type final bool required; + final String? action; // For button type: JS function name to call const ExtensionSetting({ required this.key, @@ -369,6 +370,7 @@ class ExtensionSetting { this.description, this.options, this.required = false, + this.action, }); factory ExtensionSetting.fromJson(Map json) { @@ -380,6 +382,7 @@ class ExtensionSetting { description: json['description'] as String?, options: (json['options'] as List?)?.cast(), required: json['required'] as bool? ?? false, + action: json['action'] as String?, ); } } diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 34571577..1f27ef88 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class ExtensionDetailPage extends ConsumerStatefulWidget { @@ -342,6 +343,7 @@ class _ExtensionDetailPageState extends ConsumerState { value: _settings[setting.key] ?? setting.defaultValue, showDivider: index < extension.settings.length - 1, onChanged: (value) => _updateSetting(setting.key, value), + extensionId: widget.extensionId, ); }).toList(), ), @@ -587,41 +589,62 @@ class _PermissionItem extends StatelessWidget { } } -class _SettingItem extends StatelessWidget { +class _SettingItem extends StatefulWidget { final ExtensionSetting setting; final dynamic value; final bool showDivider; final ValueChanged onChanged; + final String extensionId; const _SettingItem({ required this.setting, required this.value, required this.onChanged, + required this.extensionId, this.showDivider = true, }); + @override + State<_SettingItem> createState() => _SettingItemState(); +} + +class _SettingItemState extends State<_SettingItem> { + bool _isLoading = false; + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; Widget trailing; - switch (setting.type) { + switch (widget.setting.type) { case 'boolean': trailing = Switch( - value: value as bool? ?? false, - onChanged: onChanged, + value: widget.value as bool? ?? false, + onChanged: widget.onChanged, ); break; case 'select': trailing = DropdownButton( - value: value as String?, - items: setting.options?.map((opt) { + value: widget.value as String?, + items: widget.setting.options?.map((opt) { return DropdownMenuItem(value: opt, child: Text(opt)); }).toList(), - onChanged: onChanged, + onChanged: widget.onChanged, underline: const SizedBox(), ); break; + case 'button': + trailing = _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : FilledButton.tonal( + onPressed: () => _invokeAction(context), + child: Text(widget.setting.label), + ); + break; default: trailing = Icon( Icons.chevron_right, @@ -629,11 +652,52 @@ class _SettingItem extends StatelessWidget { ); } + // For button type, show a different layout + if (widget.setting.type == 'button') { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.setting.description != null) ...[ + Text( + widget.setting.description!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + ], + ], + ), + ), + trailing, + ], + ), + ), + if (widget.showDivider) + Divider( + height: 1, + thickness: 1, + indent: 16, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } + return Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: setting.type == 'string' || setting.type == 'number' + onTap: widget.setting.type == 'string' || widget.setting.type == 'number' ? () => _showEditDialog(context) : null, child: Padding( @@ -645,22 +709,22 @@ class _SettingItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - setting.label, + widget.setting.label, style: Theme.of(context).textTheme.bodyLarge, ), - if (setting.description != null) ...[ + if (widget.setting.description != null) ...[ const SizedBox(height: 2), Text( - setting.description!, + widget.setting.description!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], - if (setting.type == 'string' || setting.type == 'number') ...[ + if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[ const SizedBox(height: 4), Text( - value?.toString() ?? 'Not set', + widget.value?.toString() ?? 'Not set', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.primary, ), @@ -674,7 +738,7 @@ class _SettingItem extends StatelessWidget { ), ), ), - if (showDivider) + if (widget.showDivider) Divider( height: 1, thickness: 1, @@ -686,21 +750,66 @@ class _SettingItem extends StatelessWidget { ); } + Future _invokeAction(BuildContext context) async { + if (widget.setting.action == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No action defined for this button')), + ); + return; + } + + setState(() => _isLoading = true); + + try { + final result = await PlatformBridge.invokeExtensionAction( + widget.extensionId, + widget.setting.action!, + ); + + if (context.mounted) { + final success = result['success'] as bool? ?? false; + if (!success) { + final error = result['error'] as String? ?? 'Action failed'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error)), + ); + } else { + final message = result['message'] as String?; + if (message != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + void _showEditDialog(BuildContext context) { - final controller = TextEditingController(text: value?.toString() ?? ''); + final controller = TextEditingController(text: widget.value?.toString() ?? ''); final colorScheme = Theme.of(context).colorScheme; showDialog( context: context, builder: (context) => AlertDialog( - title: Text(setting.label), + title: Text(widget.setting.label), content: TextField( controller: controller, - keyboardType: setting.type == 'number' + keyboardType: widget.setting.type == 'number' ? TextInputType.number : TextInputType.text, decoration: InputDecoration( - hintText: setting.description ?? 'Enter value', + hintText: widget.setting.description ?? 'Enter value', filled: true, fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), border: OutlineInputBorder( @@ -716,10 +825,10 @@ class _SettingItem extends StatelessWidget { ), FilledButton( onPressed: () { - final newValue = setting.type == 'number' + final newValue = widget.setting.type == 'number' ? num.tryParse(controller.text) : controller.text; - onChanged(newValue); + widget.onChanged(newValue); Navigator.pop(context); }, child: Text(context.l10n.dialogSave), From 7c86ae0b7ec398718d063ea458d85d676ae9ae63 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 01:25:34 +0700 Subject: [PATCH 29/48] feat: add quick search provider switcher and genre/label for extensions - Add dropdown menu in search bar for instant provider switching - Support genre & label metadata for extension downloads - Bump version to 3.1.2 (build 61) --- CHANGELOG.md | 14 +- go_backend/extension_providers.go | 18 ++ go_backend/metadata.go | 47 +++++ lib/constants/app_info.dart | 4 +- lib/providers/download_queue_provider.dart | 2 + lib/screens/home_tab.dart | 193 ++++++++++++++++++++- lib/services/platform_bridge.dart | 4 + pubspec_ios.yaml | 2 +- 8 files changed, 278 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0569a894..f79544f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,24 @@ # Changelog -## [3.1.2] - 2026-01-18 +## [3.1.2] - 2026-01-19 ### Added +- **Quick Search Provider Switcher**: Dropdown menu in search bar for instant provider switching + - Tap the search icon to reveal a dropdown menu with all available search providers + - Shows default provider (Deezer/Spotify based on metadata source setting) at the top + - Lists all enabled extensions with custom search capability + - Displays extension icons when available + - Checkmark indicates currently selected provider + - Search hint text updates immediately when switching providers + - Re-triggers search automatically if there's existing text in the search bar + - Eliminates need to navigate to Settings > Extensions > Search Provider + - **Genre & Label Metadata**: Downloaded tracks now include genre and record label information - Fetches genre and label from Deezer album API for each track - Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files - Works automatically when Deezer track ID is available (via ISRC matching) - - Supports all download services (Tidal, Qobuz, Amazon) + - Supports all download services (Tidal, Qobuz, Amazon) and extension downloads - **MP3 Quality Option**: Optional MP3 download format with FLAC-to-MP3 conversion - New "Enable MP3 Option" toggle in Settings > Download > Audio Quality diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 9fe583ea..456bee20 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -797,6 +797,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Service: req.Source, } + // Embed genre and label if provided (from Deezer metadata) + if req.Genre != "" || req.Label != "" { + if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { + GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) + } else { + GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) + } + } + // If extension has skipMetadataEnrichment, copy metadata if ext.Manifest.SkipMetadataEnrichment { resp.SkipMetadataEnrichment = true @@ -937,6 +946,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Service: providerID, } + // Embed genre and label if provided (from Deezer metadata) + if req.Genre != "" || req.Label != "" { + if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { + GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) + } else { + GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) + } + } + // If extension has skipMetadataEnrichment and returned metadata, use it if ext.Manifest.SkipMetadataEnrichment { resp.SkipMetadataEnrichment = true diff --git a/go_backend/metadata.go b/go_backend/metadata.go index fd390704..e6f96fdc 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -375,6 +375,53 @@ func EmbedLyrics(filePath string, lyrics string) error { return f.Save(filePath) } +// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation +// This is used for extension downloads where the file is already downloaded +func EmbedGenreLabel(filePath string, genre, label string) error { + if genre == "" && label == "" { + return nil // Nothing to embed + } + + f, err := flac.ParseFile(filePath) + if err != nil { + return fmt.Errorf("failed to parse FLAC file: %w", err) + } + + var cmtIdx int = -1 + var cmt *flacvorbis.MetaDataBlockVorbisComment + + for idx, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmtIdx = idx + cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + return fmt.Errorf("failed to parse vorbis comment: %w", err) + } + break + } + } + + if cmt == nil { + cmt = flacvorbis.New() + } + + if genre != "" { + setComment(cmt, "GENRE", genre) + } + if label != "" { + setComment(cmt, "ORGANIZATION", label) + } + + cmtBlock := cmt.Marshal() + if cmtIdx >= 0 { + f.Meta[cmtIdx] = &cmtBlock + } else { + f.Meta = append(f.Meta, &cmtBlock) + } + + return f.Save(filePath) +} + // ExtractLyrics extracts embedded lyrics from a FLAC file func ExtractLyrics(filePath string) (string, error) { f, err := flac.ParseFile(filePath) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 187318d6..1dc767de 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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.1.1'; - static const String buildNumber = '60'; + static const String version = '3.1.2'; + static const String buildNumber = '61'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 3a3dc082..c78ab9c0 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1626,6 +1626,8 @@ class DownloadQueueNotifier extends Notifier { itemId: item.id, durationMs: trackToDownload.duration, source: trackToDownload.source, // Pass extension ID that provided this track + genre: genre, + label: label, ); } else if (state.autoFallback) { _log.d('Using auto-fallback mode'); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index e9b501e6..90179966 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1411,7 +1411,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient borderRadius: BorderRadius.circular(28), borderSide: BorderSide(color: colorScheme.primary, width: 2), ), - prefixIcon: const Icon(Icons.search), + prefixIcon: _SearchProviderDropdown( + onProviderChanged: () { + // Reset search state when provider changes + _lastSearchQuery = null; + // Force rebuild to update hint text + setState(() {}); + // Re-trigger search if there's text + final text = _urlController.text.trim(); + if (text.isNotEmpty && text.length >= _minLiveSearchChars) { + _performSearch(text); + } + }, + ), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1464,6 +1476,185 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } +/// Dropdown widget for quick search provider switching +class _SearchProviderDropdown extends ConsumerWidget { + final VoidCallback? onProviderChanged; + + const _SearchProviderDropdown({this.onProviderChanged}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + + // Get current provider info + final currentProvider = settings.searchProvider; + final searchProviders = extState.extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .toList(); + + // Find current provider extension + Extension? currentExt; + if (currentProvider != null && currentProvider.isNotEmpty) { + currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull; + } + + // Determine display icon + IconData displayIcon = Icons.search; + String? iconPath; + if (currentExt != null) { + iconPath = currentExt.iconPath; + if (currentExt.searchBehavior?.icon != null) { + // Use search behavior icon if available + displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); + } + } + + // Don't show dropdown if no custom search providers available + if (searchProviders.isEmpty) { + return const Icon(Icons.search); + } + + return Padding( + padding: const EdgeInsets.only(left: 8), + child: PopupMenuButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconPath != null && iconPath.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(iconPath), + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, e, st) => Icon(displayIcon, size: 20), + ), + ) + else + Icon(displayIcon, size: 20), + const SizedBox(width: 2), + Icon( + Icons.arrow_drop_down, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + tooltip: 'Change search provider', + offset: const Offset(0, 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (String providerId) { + // Empty string means default (Deezer/Spotify) + final provider = providerId.isEmpty ? null : providerId; + ref.read(settingsProvider.notifier).setSearchProvider(provider); + onProviderChanged?.call(); + }, + itemBuilder: (context) => [ + // Default option (Deezer/Spotify based on metadata source) + PopupMenuItem( + value: '', // Empty string = default provider + child: Row( + children: [ + Icon( + Icons.music_note, + size: 20, + color: currentProvider == null || currentProvider.isEmpty + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + settings.metadataSource == 'spotify' ? 'Spotify' : 'Deezer', + style: TextStyle( + fontWeight: currentProvider == null || currentProvider.isEmpty + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == null || currentProvider.isEmpty) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + ), + if (searchProviders.isNotEmpty) const PopupMenuDivider(), + // Extension providers + ...searchProviders.map((ext) => PopupMenuItem( + value: ext.id, + child: Row( + children: [ + if (ext.iconPath != null && ext.iconPath!.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(ext.iconPath!), + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, e, st) => Icon( + _getIconFromName(ext.searchBehavior?.icon), + size: 20, + color: currentProvider == ext.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ) + else + Icon( + _getIconFromName(ext.searchBehavior?.icon), + size: 20, + color: currentProvider == ext.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + ext.displayName, + style: TextStyle( + fontWeight: currentProvider == ext.id + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == ext.id) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + )), + ], + ), + ); + } + + IconData _getIconFromName(String? iconName) { + switch (iconName) { + case 'video': + case 'movie': + return Icons.video_library; + case 'music': + return Icons.music_note; + case 'podcast': + return Icons.podcasts; + case 'book': + case 'audiobook': + return Icons.menu_book; + case 'cloud': + return Icons.cloud; + case 'download': + return Icons.download; + default: + return Icons.search; + } + } +} + /// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes class _TrackItemWithStatus extends ConsumerWidget { final Track track; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 44ef3939..91632c1c 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -656,6 +656,8 @@ class PlatformBridge { String? itemId, int durationMs = 0, String? source, // Extension ID that provided this track (prioritize this extension) + String? genre, + String? label, }) async { _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); final request = jsonEncode({ @@ -678,6 +680,8 @@ class PlatformBridge { 'item_id': itemId ?? '', 'duration_ms': durationMs, 'source': source ?? '', // Extension ID that provided this track + 'genre': genre ?? '', + 'label': label ?? '', }); final result = await _channel.invokeMethod('downloadWithExtensions', request); diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index fe320546..dc2fb835 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.1.1+60 +version: 3.1.2+61 environment: sdk: ^3.10.0 From bc3055f6e136db9efad66132268c9c34f36491e8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 02:13:45 +0700 Subject: [PATCH 30/48] chore: update supported locales --- lib/l10n/supported_locales.dart | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/lib/l10n/supported_locales.dart b/lib/l10n/supported_locales.dart index e170ed11..bb331e5f 100644 --- a/lib/l10n/supported_locales.dart +++ b/lib/l10n/supported_locales.dart @@ -1,14 +1,14 @@ // GENERATED FILE - DO NOT EDIT -// Generated by: dart run tool/check_translations.dart 0 -// Only languages with >= 0% translation completion are included. +// Generated by: dart run tool/check_translations.dart 70 +// Only languages with >= 70% translation completion are included. // Translation is measured by comparing VALUES (not just key existence). // -// To regenerate, run: dart run tool/check_translations.dart 0 +// To regenerate, run: dart run tool/check_translations.dart 70 import 'package:flutter/widgets.dart'; /// Minimum translation completion threshold used to filter languages. -const int translationThreshold = 0; +const int translationThreshold = 70; /// List of locales that meet the translation threshold. /// Only these languages will be available in the app. @@ -16,17 +16,6 @@ const List filteredSupportedLocales = [ Locale('en'), Locale('ru'), Locale('id'), - Locale('ja'), - Locale('de'), - Locale('es'), - Locale('fr'), - Locale('hi'), - Locale('ko'), - Locale('nl'), - Locale('pt'), - Locale('zh'), - Locale('zh', 'CN'), - Locale('zh', 'TW'), ]; /// Set of locale codes for quick lookup. @@ -34,15 +23,4 @@ const Set filteredLocaleCodes = { 'en', 'ru', 'id', - 'ja', - 'de', - 'es', - 'fr', - 'hi', - 'ko', - 'nl', - 'pt', - 'zh', - 'zh_CN', - 'zh_TW', }; From 3c118f74e4e40f81ca686e27826d35e634e973fe Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 02:17:32 +0700 Subject: [PATCH 31/48] chore: rename ARB files and add Spanish/Portuguese languages --- CHANGELOG.md | 2 ++ lib/l10n/arb/{app_es-ES.arb => app_es_ES.arb} | 0 lib/l10n/arb/{app_pt-PT.arb => app_pt_PT.arb} | 0 lib/l10n/supported_locales.dart | 4 ++++ 4 files changed, 6 insertions(+) rename lib/l10n/arb/{app_es-ES.arb => app_es_ES.arb} (100%) rename lib/l10n/arb/{app_pt-PT.arb => app_pt_PT.arb} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f79544f9..b9e66352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **New Languages**: Added Spanish (es) and Portuguese (pt) translations + - **Quick Search Provider Switcher**: Dropdown menu in search bar for instant provider switching - Tap the search icon to reveal a dropdown menu with all available search providers - Shows default provider (Deezer/Spotify based on metadata source setting) at the top diff --git a/lib/l10n/arb/app_es-ES.arb b/lib/l10n/arb/app_es_ES.arb similarity index 100% rename from lib/l10n/arb/app_es-ES.arb rename to lib/l10n/arb/app_es_ES.arb diff --git a/lib/l10n/arb/app_pt-PT.arb b/lib/l10n/arb/app_pt_PT.arb similarity index 100% rename from lib/l10n/arb/app_pt-PT.arb rename to lib/l10n/arb/app_pt_PT.arb diff --git a/lib/l10n/supported_locales.dart b/lib/l10n/supported_locales.dart index bb331e5f..91b3afae 100644 --- a/lib/l10n/supported_locales.dart +++ b/lib/l10n/supported_locales.dart @@ -15,12 +15,16 @@ const int translationThreshold = 70; const List filteredSupportedLocales = [ Locale('en'), Locale('ru'), + Locale('es', 'ES'), Locale('id'), + Locale('pt', 'PT'), ]; /// Set of locale codes for quick lookup. const Set filteredLocaleCodes = { 'en', 'ru', + 'es_ES', 'id', + 'pt_PT', }; From a650632c4e09e7e421fd463411f67788a0cbd18b Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 02:25:30 +0700 Subject: [PATCH 32/48] feat: add translators section in about page and fix ARB locale format --- lib/l10n/app_localizations.dart | 24 + lib/l10n/app_localizations_de.dart | 38 +- lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_es.dart | 2029 ++++++++++++++++++++++++++ lib/l10n/app_localizations_fr.dart | 3 + lib/l10n/app_localizations_hi.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_ja.dart | 3 + lib/l10n/app_localizations_ko.dart | 3 + lib/l10n/app_localizations_nl.dart | 3 + lib/l10n/app_localizations_pt.dart | 2019 +++++++++++++++++++++++++ lib/l10n/app_localizations_ru.dart | 7 +- lib/l10n/app_localizations_zh.dart | 5 +- lib/l10n/arb/app_en.arb | 2 + lib/l10n/arb/app_es_ES.arb | 2 +- lib/l10n/arb/app_pt_PT.arb | 2 +- lib/l10n/arb/app_zh_CN.arb | 2 +- lib/l10n/arb/app_zh_TW.arb | 2 +- lib/screens/settings/about_page.dart | 151 +- 19 files changed, 4280 insertions(+), 24 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 07d59020..05bcad9f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -107,6 +107,7 @@ abstract class AppLocalizations { Locale('de'), Locale('en'), Locale('es'), + Locale('es', 'ES'), Locale('fr'), Locale('hi'), Locale('id'), @@ -114,6 +115,7 @@ abstract class AppLocalizations { Locale('ko'), Locale('nl'), Locale('pt'), + Locale('pt', 'PT'), Locale('ru'), Locale('zh'), Locale('zh', 'CN'), @@ -816,6 +818,12 @@ abstract class AppLocalizations { /// **'The talented artist who created our beautiful app logo!'** String get aboutLogoArtist; + /// Section for translators + /// + /// In en, this message translates to: + /// **'Translators'** + String get aboutTranslators; + /// Section for special thanks /// /// In en, this message translates to: @@ -3699,6 +3707,22 @@ class _AppLocalizationsDelegate AppLocalizations lookupAppLocalizations(Locale locale) { // Lookup logic when language+country codes are specified. switch (locale.languageCode) { + case 'es': + { + switch (locale.countryCode) { + case 'ES': + return AppLocalizationsEsEs(); + } + break; + } + case 'pt': + { + switch (locale.countryCode) { + case 'PT': + return AppLocalizationsPtPt(); + } + break; + } case 'zh': { switch (locale.countryCode) { diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b2175eb8..a02bc6c0 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -115,7 +115,7 @@ class AppLocalizationsDe extends AppLocalizations { String get settingsTitle => 'Einstellungen'; @override - String get settingsDownload => 'Download'; + String get settingsDownload => 'Herunterladen'; @override String get settingsAppearance => 'Erscheinungsbild'; @@ -130,7 +130,7 @@ class AppLocalizationsDe extends AppLocalizations { String get settingsAbout => 'Über'; @override - String get downloadTitle => 'Download'; + String get downloadTitle => 'Herunterladen'; @override String get downloadLocation => 'Download-Speicherort'; @@ -410,40 +410,46 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aboutLogoArtist => - 'The talented artist who created our beautiful app logo!'; + 'Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!'; @override - String get aboutSpecialThanks => 'Special Thanks'; + String get aboutTranslators => 'Translators'; + + @override + String get aboutSpecialThanks => 'Besonderer Dank'; @override String get aboutLinks => 'Links'; @override - String get aboutMobileSource => 'Mobile source code'; + String get aboutMobileSource => 'Mobiler Quellcode'; @override - String get aboutPCSource => 'PC source code'; + String get aboutPCSource => 'PC Quellcode'; @override - String get aboutReportIssue => 'Report an issue'; + String get aboutReportIssue => 'Problem melden'; @override - String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + String get aboutReportIssueSubtitle => + 'Melde jedes Problem, die dir auftreten'; @override - String get aboutFeatureRequest => 'Feature request'; + String get aboutFeatureRequest => 'Feature vorschlagen'; @override - String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + String get aboutFeatureRequestSubtitle => + 'Schlage neue Funktionen für die App vor'; @override String get aboutSupport => 'Support'; @override - String get aboutBuyMeCoffee => 'Buy me a coffee'; + String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee'; @override - String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; + String get aboutBuyMeCoffeeSubtitle => + 'Unterstütze die Entwicklung auf Ko-fi'; @override String get aboutApp => 'App'; @@ -453,25 +459,25 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aboutBinimumDesc => - 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + 'Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!'; @override String get aboutSachinsenalDesc => - 'The original HiFi project creator. The foundation of Tidal integration!'; + 'Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!'; @override String get aboutDoubleDouble => 'DoubleDouble'; @override String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; + 'Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!'; @override String get aboutDabMusic => 'DAB Music'; @override String get aboutDabMusicDesc => - 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + 'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!'; @override String get aboutAppDescription => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 141f8d15..eadd58ce 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -402,6 +402,9 @@ class AppLocalizationsEn extends AppLocalizations { String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Special Thanks'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 77660a73..2acbccf3 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -402,6 +402,9 @@ class AppLocalizationsEs extends AppLocalizations { String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Special Thanks'; @@ -2019,3 +2022,2029 @@ class AppLocalizationsEs extends AppLocalizations { return 'Error: $message'; } } + +/// The translations for Spanish Castilian, as used in Spain (`es_ES`). +class AppLocalizationsEsEs extends AppLocalizationsEs { + AppLocalizationsEsEs() : super('es_ES'); + + @override + String get appName => 'SpotiFLAC'; + + @override + String get appDescription => + 'Descargue pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; + + @override + String get navHome => 'Inicio'; + + @override + String get navHistory => 'Historial'; + + @override + String get navSettings => 'Ajustes'; + + @override + String get navStore => 'Tienda'; + + @override + String get homeTitle => 'Inicio'; + + @override + String get homeSearchHint => 'Pegar URL Spotify o buscar...'; + + @override + String homeSearchHintExtension(String extensionName) { + return 'Buscar con $extensionName...'; + } + + @override + String get homeSubtitle => 'Pegar enlace de Spotify o buscar por nombre'; + + @override + String get homeSupports => + 'Soportes: Pista, Álbum, Lista de reproducción, URLs de Artistas'; + + @override + String get homeRecent => 'Recientes'; + + @override + String get historyTitle => 'Historial'; + + @override + String historyDownloading(int count) { + return 'Descargando ($count)'; + } + + @override + String get historyDownloaded => 'Descargado'; + + @override + String get historyFilterAll => 'Todo'; + + @override + String get historyFilterAlbums => 'Álbumes'; + + @override + String get historyFilterSingles => 'Pistas'; + + @override + String historyTracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count pistas', + one: '1 pista', + ); + return '$_temp0'; + } + + @override + String historyAlbumsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count álbumes', + one: '1 álbum', + ); + return '$_temp0'; + } + + @override + String get historyNoDownloads => 'No hay historial de descargas'; + + @override + String get historyNoDownloadsSubtitle => + 'Las pistas descargadas aparecerán aquí'; + + @override + String get historyNoAlbums => 'No hay descargas de álbum'; + + @override + String get historyNoAlbumsSubtitle => + 'Descargar múltiples pistas de un álbum para verlas aquí'; + + @override + String get historyNoSingles => 'No hay descargas'; + + @override + String get historyNoSinglesSubtitle => + 'Las descargas de una sola pista aparecerán aquí'; + + @override + String get settingsTitle => 'Ajustes'; + + @override + String get settingsDownload => 'Descargar'; + + @override + String get settingsAppearance => 'Apariencia'; + + @override + String get settingsOptions => 'Opciones'; + + @override + String get settingsExtensions => 'Extensiones'; + + @override + String get settingsAbout => 'Acerca de'; + + @override + String get downloadTitle => 'Descargar'; + + @override + String get downloadLocation => 'Ubicación de descarga'; + + @override + String get downloadLocationSubtitle => 'Elija dónde guardar los archivos'; + + @override + String get downloadLocationDefault => 'Ubicación predeterminada'; + + @override + String get downloadDefaultService => 'Servicio por defecto'; + + @override + String get downloadDefaultServiceSubtitle => 'Servicio usado para descargas'; + + @override + String get downloadDefaultQuality => 'Calidad por defecto'; + + @override + String get downloadAskQuality => 'Preguntar calidad antes de descargar'; + + @override + String get downloadAskQualitySubtitle => + 'Mostrar selector de calidad para cada descarga'; + + @override + String get downloadFilenameFormat => 'Formato del nombre del archivo'; + + @override + String get downloadFolderOrganization => 'Organización de carpetas'; + + @override + String get downloadSeparateSingles => 'Separar Pistas'; + + @override + String get downloadSeparateSinglesSubtitle => + 'Colocar pistas individuales en una carpeta separada'; + + @override + String get qualityBest => 'Mejor disponible'; + + @override + String get qualityFlac => 'FLAC'; + + @override + String get quality320 => '320 kbps'; + + @override + String get quality128 => '128 kbps'; + + @override + String get appearanceTitle => 'Apariencia'; + + @override + String get appearanceTheme => 'Tema'; + + @override + String get appearanceThemeSystem => 'Sistema'; + + @override + String get appearanceThemeLight => 'Claro'; + + @override + String get appearanceThemeDark => 'Oscuro'; + + @override + String get appearanceDynamicColor => 'Color dinámico'; + + @override + String get appearanceDynamicColorSubtitle => + 'Usar colores de tu fondo de pantalla'; + + @override + String get appearanceAccentColor => 'Color Secundario'; + + @override + String get appearanceHistoryView => 'Vista de Historial'; + + @override + String get appearanceHistoryViewList => 'Lista'; + + @override + String get appearanceHistoryViewGrid => 'Cuadrícula'; + + @override + String get optionsTitle => 'Opciones'; + + @override + String get optionsSearchSource => 'Buscar Fuente'; + + @override + String get optionsPrimaryProvider => 'Proveedor Principal'; + + @override + String get optionsPrimaryProviderSubtitle => + 'Servicio usado al buscar por nombre de la pista.'; + + @override + String optionsUsingExtension(String extensionName) { + return 'Usando la extensión: $extensionName'; + } + + @override + String get optionsSwitchBack => + 'Toque Deezer o Spotify para volver desde la extensión'; + + @override + String get optionsAutoFallback => 'Alternativa automática'; + + @override + String get optionsAutoFallbackSubtitle => + 'Pruebe otros servicios si falla la descarga'; + + @override + String get optionsUseExtensionProviders => 'Usar proveedores de extensiones'; + + @override + String get optionsUseExtensionProvidersOn => + 'Las extensiones serán probadas primero'; + + @override + String get optionsUseExtensionProvidersOff => + 'Utilizando sólo proveedores integrados'; + + @override + String get optionsEmbedLyrics => 'Incrustar Letras'; + + @override + String get optionsEmbedLyricsSubtitle => + 'Insertar letras sincronizadas en archivos FLAC'; + + @override + String get optionsMaxQualityCover => 'Carátula de calidad máxima'; + + @override + String get optionsMaxQualityCoverSubtitle => + 'Descargar carátula de resolución máxima'; + + @override + String get optionsConcurrentDownloads => 'Descargas Simultáneas'; + + @override + String get optionsConcurrentSequential => 'Secuencial (1 a la vez)'; + + @override + String optionsConcurrentParallel(int count) { + return '$count descargas paralelas'; + } + + @override + String get optionsConcurrentWarning => + 'Las descargas paralelas pueden activar la limitación de velocidad'; + + @override + String get optionsExtensionStore => 'Tienda de extensiones'; + + @override + String get optionsExtensionStoreSubtitle => + 'Mostrar pestaña de tienda en la navegación'; + + @override + String get optionsCheckUpdates => 'Comprobar actualizaciones'; + + @override + String get optionsCheckUpdatesSubtitle => + 'Notificar cuando una nueva versión esté disponible'; + + @override + String get optionsUpdateChannel => 'Tipo de actualizaciones'; + + @override + String get optionsUpdateChannelStable => 'Sólo versiones estables'; + + @override + String get optionsUpdateChannelPreview => 'Versión preliminar'; + + @override + String get optionsUpdateChannelWarning => + 'La Versión preliminar puede contener errores o características incompletas'; + + @override + String get optionsClearHistory => 'Borrar el historial de descargas'; + + @override + String get optionsClearHistorySubtitle => + 'Eliminar todas las pistas descargadas del historial'; + + @override + String get optionsDetailedLogging => 'Registro detallado'; + + @override + String get optionsDetailedLoggingOn => + 'Registros detallados están siendo registrados'; + + @override + String get optionsDetailedLoggingOff => 'Habilitar para informes de errores'; + + @override + String get optionsSpotifyCredentials => 'Credenciales de Spotify'; + + @override + String optionsSpotifyCredentialsConfigured(String clientId) { + return 'ID de cliente: $clientId...'; + } + + @override + String get optionsSpotifyCredentialsRequired => + 'Requerido - toque para configurar'; + + @override + String get optionsSpotifyWarning => + 'Spotify requiere tus propias credenciales API. Obténgalas gratis de developer.spotify.com'; + + @override + String get extensionsTitle => 'Extensiones'; + + @override + String get extensionsInstalled => 'Extensiones instaladas'; + + @override + String get extensionsNone => 'No hay extensiones instaladas'; + + @override + String get extensionsNoneSubtitle => + 'Instalar extensiones desde la pestaña Tienda'; + + @override + String get extensionsEnabled => 'Habilitado'; + + @override + String get extensionsDisabled => 'Deshabilitado'; + + @override + String extensionsVersion(String version) { + return 'Versión $version'; + } + + @override + String extensionsAuthor(String author) { + return 'por $author'; + } + + @override + String get extensionsUninstall => 'Desinstalar'; + + @override + String get extensionsSetAsSearch => 'Establecer como proveedor de búsqueda'; + + @override + String get storeTitle => 'Tienda de extensiones'; + + @override + String get storeSearch => 'Buscar extensiones...'; + + @override + String get storeInstall => 'Instalar'; + + @override + String get storeInstalled => 'Instalada'; + + @override + String get storeUpdate => 'Actualizar'; + + @override + String get aboutTitle => 'Acerca de'; + + @override + String get aboutContributors => 'Colaboradores'; + + @override + String get aboutMobileDeveloper => 'Desarrollador de versiones móviles'; + + @override + String get aboutOriginalCreator => 'Creador original de SpotiFLAC'; + + @override + String get aboutLogoArtist => + '¡El talentoso artista que creó nuestro hermoso logo!'; + + @override + String get aboutSpecialThanks => 'Agradecimientos especiales'; + + @override + String get aboutLinks => 'Enlaces'; + + @override + String get aboutMobileSource => 'Código fuente móvil'; + + @override + String get aboutPCSource => 'Código fuente de PC'; + + @override + String get aboutReportIssue => 'Reportar un problema'; + + @override + String get aboutReportIssueSubtitle => + 'Reporta cualquier problema que encuentres'; + + @override + String get aboutFeatureRequest => 'Sugerir una función'; + + @override + String get aboutFeatureRequestSubtitle => + 'Sugerir nuevas funciones para la aplicación'; + + @override + String get aboutSupport => 'Soporte'; + + @override + String get aboutBuyMeCoffee => 'Invítame a un café'; + + @override + String get aboutBuyMeCoffeeSubtitle => 'Apoyar el desarrollo en Ko-fi'; + + @override + String get aboutApp => 'Aplicación'; + + @override + String get aboutVersion => 'Versión'; + + @override + String get aboutBinimumDesc => + 'El creador de la API QQDL & Hi-Fi. ¡Sin esta API, las descargas de Tidal no existiría!'; + + @override + String get aboutSachinsenalDesc => + 'El creador original del proyecto Hi-Fi. ¡La base de la integración de Tidal!'; + + @override + String get aboutDoubleDouble => 'DoubleDouble'; + + @override + String get aboutDoubleDoubleDesc => + 'API increible para descargas de Amazon Music. ¡Gracias por hacerla gratis!'; + + @override + String get aboutDabMusic => 'Música DAB'; + + @override + String get aboutDabMusicDesc => + 'La mejor API de streaming de Qobuz. ¡Las descargas de Hi-Res no serían posibles sin esto!'; + + @override + String get aboutAppDescription => + 'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; + + @override + String get albumTitle => 'Álbum'; + + @override + String albumTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count pistas', + one: '1 pista', + ); + return '$_temp0'; + } + + @override + String get albumDownloadAll => 'Descargar Todo'; + + @override + String get albumDownloadRemaining => 'Descargas Restantes'; + + @override + String get playlistTitle => 'Lista de reproducción'; + + @override + String get artistTitle => 'Artista'; + + @override + String get artistAlbums => 'Álbumes'; + + @override + String get artistSingles => 'Pistas y EPs'; + + @override + String get artistCompilations => 'Compilaciones'; + + @override + String artistReleases(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count lanzamientos', + one: '1 lanzamiento', + ); + return '$_temp0'; + } + + @override + String get artistPopular => 'Populares'; + + @override + String artistMonthlyListeners(String count) { + return '$count oyentes mensuales'; + } + + @override + String get trackMetadataTitle => 'Información de pista'; + + @override + String get trackMetadataArtist => 'Artista'; + + @override + String get trackMetadataAlbum => 'Álbum'; + + @override + String get trackMetadataDuration => 'Duración'; + + @override + String get trackMetadataQuality => 'Calidad'; + + @override + String get trackMetadataPath => 'Ruta del archivo'; + + @override + String get trackMetadataDownloadedAt => 'Descargado'; + + @override + String get trackMetadataService => 'Servicio'; + + @override + String get trackMetadataPlay => 'Reproducir'; + + @override + String get trackMetadataShare => 'Compartir'; + + @override + String get trackMetadataDelete => 'Eliminar'; + + @override + String get trackMetadataRedownload => 'Volver a descargar'; + + @override + String get trackMetadataOpenFolder => 'Abrir carpeta'; + + @override + String get setupTitle => 'Bienvenido a SpotiFLAC'; + + @override + String get setupSubtitle => 'Comencemos'; + + @override + String get setupStoragePermission => 'Permiso de almacenamiento'; + + @override + String get setupStoragePermissionSubtitle => + 'Necesario para guardar los archivos descargados'; + + @override + String get setupStoragePermissionGranted => 'Permiso aprobado'; + + @override + String get setupStoragePermissionDenied => 'Permiso denegado'; + + @override + String get setupGrantPermission => 'Conceder permiso'; + + @override + String get setupDownloadLocation => 'Ubicación de descarga'; + + @override + String get setupChooseFolder => 'Seleccionar Carpeta'; + + @override + String get setupContinue => 'Continuar'; + + @override + String get setupSkip => 'Omitir por ahora'; + + @override + String get setupStorageAccessRequired => 'Acceso al almacenamiento requerido'; + + @override + String get setupStorageAccessMessage => + 'SpotiFLAC necesita permiso de \"Todos los archivos de acceso\" para guardar los archivos de música en la carpeta elegida.'; + + @override + String get setupStorageAccessMessageAndroid11 => + 'Android 11+ requiere permiso \"Todos los archivos de acceso\" para guardar los archivos en la carpeta de descargas elegida.'; + + @override + String get setupOpenSettings => 'Abrir ajustes'; + + @override + String get setupPermissionDeniedMessage => + 'Permiso denegado. Por favor, conceda todos los permisos para continuar.'; + + @override + String setupPermissionRequired(String permissionType) { + return 'Permiso de $permissionType requerido'; + } + + @override + String setupPermissionRequiredMessage(String permissionType) { + return 'Se requiere un permiso $permissionType para la mejor experiencia. Puedes cambiar esto más tarde en ajustes.'; + } + + @override + String get setupSelectDownloadFolder => 'Seleccionar carpeta de descarga'; + + @override + String get setupUseDefaultFolder => '¿Usar carpeta por defecto?'; + + @override + String get setupNoFolderSelected => + 'No se ha seleccionado ninguna carpeta. ¿Desea utilizar la carpeta por defecto?'; + + @override + String get setupUseDefault => 'Usar por defecto'; + + @override + String get setupDownloadLocationTitle => 'Ubicación de descarga'; + + @override + String get setupDownloadLocationIosMessage => + 'En iOS, las descargas se guardan en la carpeta de documentos de la aplicación. Puede acceder a ellas desde la aplicación Archivos.'; + + @override + String get setupAppDocumentsFolder => 'Carpeta de documentos de App'; + + @override + String get setupAppDocumentsFolderSubtitle => + 'Recomendado - accesible desde la aplicación Archivos'; + + @override + String get setupChooseFromFiles => 'Elegir de archivos'; + + @override + String get setupChooseFromFilesSubtitle => + 'Seleccione iCloud u otra ubicación'; + + @override + String get setupIosEmptyFolderWarning => + 'Limitación de iOS: No se pueden seleccionar carpetas vacías. Elige una carpeta con al menos un archivo.'; + + @override + String get setupDownloadInFlac => 'Descargar pistas de Spotify en FLAC'; + + @override + String get setupStepStorage => 'Almacenamiento'; + + @override + String get setupStepNotification => 'Notificación'; + + @override + String get setupStepFolder => 'Carpeta'; + + @override + String get setupStepSpotify => 'Spotify'; + + @override + String get setupStepPermission => 'Permiso'; + + @override + String get setupStorageGranted => '¡Permiso de almacenamiento concedido!'; + + @override + String get setupStorageRequired => 'Permiso de almacenamiento requerido'; + + @override + String get setupStorageDescription => + 'SpotiFLAC necesita permiso de almacenamiento para guardar sus archivos de música descargados.'; + + @override + String get setupNotificationGranted => + '¡Acceso a las notificaciones permitido!'; + + @override + String get setupNotificationEnable => 'Activar notificaciones'; + + @override + String get setupNotificationDescription => + 'Recibe notificaciones cuando las descargas completen o requieran atención.'; + + @override + String get setupFolderSelected => '¡Carpeta de descarga seleccionada!'; + + @override + String get setupFolderChoose => 'Cambiar carpeta de descargas'; + + @override + String get setupFolderDescription => + 'Seleccione una carpeta donde se guardará la música descargada.'; + + @override + String get setupChangeFolder => 'Cambiar carpeta'; + + @override + String get setupSelectFolder => 'Seleccionar Carpeta'; + + @override + String get setupSpotifyApiOptional => 'API de Spotify (opcional)'; + + @override + String get setupSpotifyApiDescription => + 'Añade tus credenciales de la API de Spotify para mejores resultados de búsqueda y acceso al contenido exclusivo de Spotify.'; + + @override + String get setupUseSpotifyApi => 'Usar API de Spotify'; + + @override + String get setupEnterCredentialsBelow => + 'Ingresa tus credenciales a continuación'; + + @override + String get setupUsingDeezer => 'Usando Deezer (no se necesita cuenta)'; + + @override + String get setupEnterClientId => 'Introduzca el ID de cliente de Spotify'; + + @override + String get setupEnterClientSecret => 'Ingresa el Client Secret de Spotify'; + + @override + String get setupGetFreeCredentials => + 'Obtén tus credenciales gratuitas de la API desde el Spotify Developer Dashboard.'; + + @override + String get setupEnableNotifications => 'Activar notificaciones'; + + @override + String get setupProceedToNextStep => + 'Ahora puedes continuar con el siguiente paso.'; + + @override + String get setupNotificationProgressDescription => + 'Recibirás notificaciones de progreso de descargas.'; + + @override + String get setupNotificationBackgroundDescription => + 'Recibe notificaciones sobre el progreso de la descarga y la finalización. Esto te ayuda a rastrear las descargas cuando la aplicación está en segundo plano.'; + + @override + String get setupSkipForNow => 'Omitir por ahora'; + + @override + String get setupBack => 'Atrás'; + + @override + String get setupNext => 'Siguiente'; + + @override + String get setupGetStarted => 'Empezar'; + + @override + String get setupSkipAndStart => 'Saltar y empezar'; + + @override + String get setupAllowAccessToManageFiles => + 'Por favor, activa \"Permitir el acceso para gestionar todos los archivos\" en la siguiente pantalla.'; + + @override + String get setupGetCredentialsFromSpotify => + 'Obtener credenciales de developer.spotify.com'; + + @override + String get dialogCancel => 'Cancelar'; + + @override + String get dialogOk => 'Aceptar'; + + @override + String get dialogSave => 'Guardar'; + + @override + String get dialogDelete => 'Eliminar'; + + @override + String get dialogRetry => 'Volver a intentar'; + + @override + String get dialogClose => 'Cerrar'; + + @override + String get dialogYes => 'Sí'; + + @override + String get dialogNo => 'No'; + + @override + String get dialogClear => 'Borrar'; + + @override + String get dialogConfirm => 'Confirmar'; + + @override + String get dialogDone => 'Hecho'; + + @override + String get dialogImport => 'Importar'; + + @override + String get dialogDiscard => 'Descartar'; + + @override + String get dialogRemove => 'Eliminar'; + + @override + String get dialogUninstall => 'Desinstalar'; + + @override + String get dialogDiscardChanges => '¿Descartar cambios?'; + + @override + String get dialogUnsavedChanges => + 'Tienes cambios sin guardar. ¿Quieres descartarlos?'; + + @override + String get dialogDownloadFailed => 'Descarga fallida'; + + @override + String get dialogTrackLabel => 'Pista:'; + + @override + String get dialogArtistLabel => 'Artista:'; + + @override + String get dialogErrorLabel => 'Error:'; + + @override + String get dialogClearAll => 'Eliminar todo'; + + @override + String get dialogClearAllDownloads => + '¿Estás seguro de que quieres borrar todas las descargas?'; + + @override + String get dialogRemoveFromDevice => '¿Eliminar del dispositivo?'; + + @override + String get dialogRemoveExtension => 'Eliminar extensión'; + + @override + String get dialogRemoveExtensionMessage => + '¿Estás seguro de que quieres eliminar esta extensión? Esto no se puede deshacer.'; + + @override + String get dialogUninstallExtension => '¿Desinstalar extensión?'; + + @override + String dialogUninstallExtensionMessage(String extensionName) { + return '¿Estás seguro de que quieres eliminar $extensionName?'; + } + + @override + String get dialogClearHistoryTitle => 'Borrar historial'; + + @override + String get dialogClearHistoryMessage => + '¿Estás seguro de que quieres borrar todo el historial de descargas? Esta acción no se puede deshacer.'; + + @override + String get dialogDeleteSelectedTitle => 'Borrar Seleccionados'; + + @override + String dialogDeleteSelectedMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return '¿Eliminar $count $_temp0 del historial?\n\nEsto también eliminará los archivos del almacenamiento.'; + } + + @override + String get dialogImportPlaylistTitle => 'Importar lista de reproducción'; + + @override + String dialogImportPlaylistMessage(int count) { + return 'Se han encontrado pistas $count en CSV. ¿Añadirlas para descargar la cola?'; + } + + @override + String snackbarAddedToQueue(String trackName) { + return 'Añadido \"$trackName\" a la cola'; + } + + @override + String snackbarAddedTracksToQueue(int count) { + return 'Añadidas pistas $count a la cola'; + } + + @override + String snackbarAlreadyDownloaded(String trackName) { + return '\"$trackName\" ya descargado'; + } + + @override + String get snackbarHistoryCleared => 'Historial borrado'; + + @override + String get snackbarCredentialsSaved => 'Credenciales guardadas'; + + @override + String get snackbarCredentialsCleared => 'Credenciales borradas'; + + @override + String snackbarDeletedTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return 'Eliminado $count $_temp0'; + } + + @override + String snackbarCannotOpenFile(String error) { + return 'No se puede abrir el archivo: $error'; + } + + @override + String get snackbarFillAllFields => 'Por favor, completa todos los campos'; + + @override + String get snackbarViewQueue => 'Ver cola'; + + @override + String snackbarFailedToLoad(String error) { + return 'Error al cargar: $error'; + } + + @override + String snackbarUrlCopied(String platform) { + return 'URL $platform copiada al portapapeles'; + } + + @override + String get snackbarFileNotFound => 'Archivo no encontrado'; + + @override + String get snackbarSelectExtFile => + 'Por favor, seleccione un archivo .spotiflac-ext'; + + @override + String get snackbarProviderPrioritySaved => 'Prioridad de proveedor guardada'; + + @override + String get snackbarMetadataProviderSaved => + 'Prioridad de proveedor de metadatos guardada'; + + @override + String snackbarExtensionInstalled(String extensionName) { + return '$extensionName instalado.'; + } + + @override + String snackbarExtensionUpdated(String extensionName) { + return '$extensionName actualizada.'; + } + + @override + String get snackbarFailedToInstall => 'Fallo al instalar la extensión'; + + @override + String get snackbarFailedToUpdate => 'Error al actualizar la extensión'; + + @override + String get errorRateLimited => 'Límite Excedido'; + + @override + String get errorRateLimitedMessage => + 'Demasiadas solicitudes. Por favor, espere un momento antes de buscar de nuevo.'; + + @override + String errorFailedToLoad(String item) { + return 'Error al cargar $item'; + } + + @override + String get errorNoTracksFound => 'No se encontraron pistas'; + + @override + String errorMissingExtensionSource(String item) { + return 'No se puede cargar $item: falta una fuente de extensión'; + } + + @override + String get statusQueued => 'En cola'; + + @override + String get statusDownloading => 'Descargando'; + + @override + String get statusFinalizing => 'Finalizando'; + + @override + String get statusCompleted => 'Completado'; + + @override + String get statusFailed => 'Error'; + + @override + String get statusSkipped => 'Omitido'; + + @override + String get statusPaused => 'Pausado'; + + @override + String get actionPause => 'Pausar'; + + @override + String get actionResume => 'Reanudar'; + + @override + String get actionCancel => 'Cancelar'; + + @override + String get actionStop => 'Detener'; + + @override + String get actionSelect => 'Seleccionar'; + + @override + String get actionSelectAll => 'Seleccionar Todo'; + + @override + String get actionDeselect => 'Deseleccionar'; + + @override + String get actionPaste => 'Pegar'; + + @override + String get actionImportCsv => 'Importar CSV'; + + @override + String get actionRemoveCredentials => 'Eliminar credenciales'; + + @override + String get actionSaveCredentials => 'Guardar credenciales'; + + @override + String selectionSelected(int count) { + return '$count seleccionado'; + } + + @override + String get selectionAllSelected => 'Todas las pistas seleccionadas'; + + @override + String get selectionTapToSelect => 'Toca las pistas para seleccionar'; + + @override + String selectionDeleteTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return '¡Eliminar $count $_temp0'; + } + + @override + String get selectionSelectToDelete => 'Seleccionar pistas a eliminar'; + + @override + String progressFetchingMetadata(int current, int total) { + return 'Obteniendo metadatos... $current/$total'; + } + + @override + String get progressReadingCsv => 'Leyendo CSV...'; + + @override + String get searchSongs => 'Canciones'; + + @override + String get searchArtists => 'Artistas'; + + @override + String get searchAlbums => 'Álbumes'; + + @override + String get searchPlaylists => 'Listas de reproducción'; + + @override + String get tooltipPlay => 'Reproducir'; + + @override + String get tooltipCancel => 'Cancelar'; + + @override + String get tooltipStop => 'Detener'; + + @override + String get tooltipRetry => 'Volver a intentar'; + + @override + String get tooltipRemove => 'Eliminar'; + + @override + String get tooltipClear => 'Borrar'; + + @override + String get tooltipPaste => 'Pegar'; + + @override + String get filenameFormat => 'Formato del nombre del archivo'; + + @override + String filenameFormatPreview(String preview) { + return 'Vista previa: $preview'; + } + + @override + String get filenameAvailablePlaceholders => 'Marcadores disponibles:'; + + @override + String filenameHint(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get folderOrganization => 'Organización de carpetas'; + + @override + String get folderOrganizationNone => 'Ninguna organización'; + + @override + String get folderOrganizationByArtist => 'Por Artista'; + + @override + String get folderOrganizationByAlbum => 'Por Álbum'; + + @override + String get folderOrganizationByArtistAlbum => 'Artista/Álbum'; + + @override + String get folderOrganizationDescription => + 'Organizar los archivos descargados en carpetas'; + + @override + String get folderOrganizationNoneSubtitle => + 'Todos los archivos de la carpeta de descargas'; + + @override + String get folderOrganizationByArtistSubtitle => + 'Carpeta separada para cada artista'; + + @override + String get folderOrganizationByAlbumSubtitle => + 'Carpeta separada para cada artista'; + + @override + String get folderOrganizationByArtistAlbumSubtitle => + 'Carpetas organizadas por artista y álbum'; + + @override + String get updateAvailable => 'Actualización Disponible'; + + @override + String updateNewVersion(String version) { + return 'Versión $version está disponible'; + } + + @override + String get updateDownload => 'Descargar'; + + @override + String get updateLater => 'Más tarde'; + + @override + String get updateChangelog => 'Historial de cambios'; + + @override + String get updateStartingDownload => 'Iniciando descarga...'; + + @override + String get updateDownloadFailed => 'Descarga fallida'; + + @override + String get updateFailedMessage => 'Error al descargar la actualización'; + + @override + String get updateNewVersionReady => 'Una nueva versión está lista'; + + @override + String get updateCurrent => 'Actual'; + + @override + String get updateNew => 'Nuevo'; + + @override + String get updateDownloading => 'Descargando...'; + + @override + String get updateWhatsNew => 'Novedades'; + + @override + String get updateDownloadInstall => 'Descargar & Instalar'; + + @override + String get updateDontRemind => 'No recordar'; + + @override + String get providerPriority => 'Prioridad del proveedor'; + + @override + String get providerPrioritySubtitle => + 'Arrastre para reordenar los proveedores de descarga'; + + @override + String get providerPriorityTitle => 'Prioridad del proveedor'; + + @override + String get providerPriorityDescription => + 'Arrastra para reordenar los proveedores de descarga. La aplicación intentará usar los proveedores de arriba hacia abajo al descargar las pistas.'; + + @override + String get providerPriorityInfo => + 'Si una pista no está disponible en el primer proveedor, la aplicación intentará automáticamente el siguiente.'; + + @override + String get providerBuiltIn => 'Integrado'; + + @override + String get providerExtension => 'Extensión'; + + @override + String get metadataProviderPriority => 'Prioridad del proveedor de metadatos'; + + @override + String get metadataProviderPrioritySubtitle => + 'Orden usado al recuperar metadatos de la pista'; + + @override + String get metadataProviderPriorityTitle => 'Prioridad de los metadatos'; + + @override + String get metadataProviderPriorityDescription => + 'Arrastra para reordenar los proveedores de metadatos. La aplicación probará los proveedores de arriba hacia abajo al buscar pistas y obtener los metadatos.'; + + @override + String get metadataProviderPriorityInfo => + 'Deezer no tiene límites de tasa y se recomienda como principal. Spotify puede valorar el límite después de muchas solicitudes.'; + + @override + String get metadataNoRateLimits => 'Sin límites de tasa'; + + @override + String get metadataMayRateLimit => 'Sin límites de tasa'; + + @override + String get logTitle => 'Registros'; + + @override + String get logCopy => 'Copiar Registros'; + + @override + String get logClear => 'Limpiar registros'; + + @override + String get logShare => 'Compartir Registros'; + + @override + String get logEmpty => 'No hay registros aún'; + + @override + String get logCopied => 'Registros copiados al portapapeles'; + + @override + String get logSearchHint => 'Buscar registros...'; + + @override + String get logFilterLevel => 'Nivel'; + + @override + String get logFilterSection => 'Filtrar'; + + @override + String get logShareLogs => 'Compartir registros'; + + @override + String get logClearLogs => 'Borrar registros'; + + @override + String get logClearLogsTitle => 'Limpiar registros'; + + @override + String get logClearLogsMessage => + '¿Estás seguro que deseas limpiar todos los registros?'; + + @override + String get logIspBlocking => 'BLOQUEO POR EL ISP DETECTADO'; + + @override + String get logRateLimited => 'TASA LIMITADA'; + + @override + String get logNetworkError => 'ERROR DE RED'; + + @override + String get logTrackNotFound => 'PISTA NO ENCONTRADA'; + + @override + String get logFilterBySeverity => 'Filtrar los registros por gravedad'; + + @override + String get logNoLogsYet => 'No hay registros aún'; + + @override + String get logNoLogsYetSubtitle => + 'Los registros aparecerán aquí mientras usas la aplicación'; + + @override + String get logIssueSummary => 'Resumen de Incidencias'; + + @override + String get logIspBlockingDescription => + 'Tu ISP puede estar bloqueando el acceso a los servicios de descarga'; + + @override + String get logIspBlockingSuggestion => + 'Intente usar una VPN o cambie el DNS a 1.1.1.1 o 8.8.8.8'; + + @override + String get logRateLimitedDescription => 'Demasiadas solicitudes al servicio'; + + @override + String get logRateLimitedSuggestion => + 'Espere unos minutos antes de volver a intentarlo'; + + @override + String get logNetworkErrorDescription => 'Problemas de conexión detectados'; + + @override + String get logNetworkErrorSuggestion => 'Comprueba tu conexión a internet'; + + @override + String get logTrackNotFoundDescription => + 'No se pudieron encontrar algunas pistas en los servicios de descarga'; + + @override + String get logTrackNotFoundSuggestion => + 'La pista puede no estar disponible en calidad sin pérdida'; + + @override + String logTotalErrors(int count) { + return 'Total de errores: $count'; + } + + @override + String logAffected(String domains) { + return 'Afectado: $domains'; + } + + @override + String logEntriesFiltered(int count) { + return 'Entradas ($count filtradas)'; + } + + @override + String logEntries(int count) { + return 'Entradas ($count)'; + } + + @override + String get credentialsTitle => 'Credenciales de Spotify'; + + @override + String get credentialsDescription => + 'Introduzca su ID de cliente y secreto para utilizar su propia cuota de aplicación de Spotify.'; + + @override + String get credentialsClientId => 'ID del cliente'; + + @override + String get credentialsClientIdHint => 'Pegar ID de cliente'; + + @override + String get credentialsClientSecret => 'Client Secret'; + + @override + String get credentialsClientSecretHint => 'Pegar Client Secret'; + + @override + String get channelStable => 'Estable'; + + @override + String get channelPreview => 'Vista previa'; + + @override + String get sectionSearchSource => 'Buscar Fuente'; + + @override + String get sectionDownload => 'Descargar'; + + @override + String get sectionPerformance => 'Alto rendimiento'; + + @override + String get sectionApp => 'Aplicación'; + + @override + String get sectionData => 'Datos'; + + @override + String get sectionDebug => 'Depuración'; + + @override + String get sectionService => 'Servicio'; + + @override + String get sectionAudioQuality => 'Calidad de Sonido'; + + @override + String get sectionFileSettings => 'Ajustes del archivo'; + + @override + String get sectionColor => 'Colores'; + + @override + String get sectionTheme => 'Tema'; + + @override + String get sectionLayout => 'Diseño'; + + @override + String get sectionLanguage => 'Idioma'; + + @override + String get appearanceLanguage => 'Idioma de la aplicación'; + + @override + String get appearanceLanguageSubtitle => 'Elija su idioma preferido'; + + @override + String get settingsAppearanceSubtitle => 'Tema, colores, pantalla'; + + @override + String get settingsDownloadSubtitle => + 'Servicio, calidad, formato del nombre del archivo'; + + @override + String get settingsOptionsSubtitle => + 'Alternativa, letras, carátula, actualizaciones'; + + @override + String get settingsExtensionsSubtitle => + 'Administrar proveedores de descarga'; + + @override + String get settingsLogsSubtitle => + 'Ver registros de aplicaciones para depuración'; + + @override + String get loadingSharedLink => 'Cargando enlace compartido...'; + + @override + String get pressBackAgainToExit => 'Presione de nuevo para salir'; + + @override + String get tracksHeader => 'Pistas'; + + @override + String downloadAllCount(int count) { + return 'Descargar Todo ($count)'; + } + + @override + String tracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count pistas', + one: '1 pista', + ); + return '$_temp0'; + } + + @override + String get trackCopyFilePath => 'Copiar ruta de archivo'; + + @override + String get trackRemoveFromDevice => 'Eliminar del dispositivo'; + + @override + String get trackLoadLyrics => 'Cargar letras'; + + @override + String get trackMetadata => 'Metadatos'; + + @override + String get trackFileInfo => 'Información de archivo'; + + @override + String get trackLyrics => 'Letras'; + + @override + String get trackFileNotFound => 'Archivo no encontrado'; + + @override + String get trackOpenInDeezer => 'Abrir en Deezer'; + + @override + String get trackOpenInSpotify => 'Abrir en Spotify'; + + @override + String get trackTrackName => 'Nombre de pista'; + + @override + String get trackArtist => 'Artista'; + + @override + String get trackAlbumArtist => 'Artista del álbum'; + + @override + String get trackAlbum => 'Álbum'; + + @override + String get trackTrackNumber => 'Número de pista'; + + @override + String get trackDiscNumber => 'Número de disco'; + + @override + String get trackDuration => 'Duración'; + + @override + String get trackAudioQuality => 'Calidad del sonido'; + + @override + String get trackReleaseDate => 'Fecha de lanzamiento'; + + @override + String get trackDownloaded => 'Descargado'; + + @override + String get trackCopyLyrics => 'Copiar letras'; + + @override + String get trackLyricsNotAvailable => 'Letras no disponibles para este tema'; + + @override + String get trackLyricsTimeout => + 'Tiempo de espera agotado. Inténtalo de nuevo más tarde.'; + + @override + String get trackLyricsLoadFailed => 'Error al cargar la letra'; + + @override + String get trackCopiedToClipboard => 'Copiado al portapapeles'; + + @override + String get trackDeleteConfirmTitle => '¿Eliminar del dispositivo?'; + + @override + String get trackDeleteConfirmMessage => + 'Esto eliminará permanentemente el archivo descargado y lo eliminará de tu historial.'; + + @override + String trackCannotOpen(String message) { + return 'No se puede abrir: $message'; + } + + @override + String get dateToday => 'Hoy'; + + @override + String get dateYesterday => 'Ayer'; + + @override + String dateDaysAgo(int count) { + return 'Hace $count días'; + } + + @override + String dateWeeksAgo(int count) { + return '$count semanas antes'; + } + + @override + String dateMonthsAgo(int count) { + return '$count meses atrás'; + } + + @override + String get concurrentSequential => 'Secuencial'; + + @override + String get concurrentParallel2 => '2 simultáneamente'; + + @override + String get concurrentParallel3 => '3 simultáneamente'; + + @override + String get tapToSeeError => 'Pulse para ver los detalles del error'; + + @override + String get storeFilterAll => 'Todo'; + + @override + String get storeFilterMetadata => 'Metadatos'; + + @override + String get storeFilterDownload => 'Descargar'; + + @override + String get storeFilterUtility => 'Utilidad'; + + @override + String get storeFilterLyrics => 'Letras'; + + @override + String get storeFilterIntegration => 'Integración'; + + @override + String get storeClearFilters => 'Limpiar filtros'; + + @override + String get storeNoResults => 'No se encontraron extensiones'; + + @override + String get extensionProviderPriority => 'Prioridad del proveedor'; + + @override + String get extensionInstallButton => 'Instalar extensión'; + + @override + String get extensionDefaultProvider => 'Por defecto (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Usar búsqueda integrada'; + + @override + String get extensionAuthor => 'Autor/a'; + + @override + String get extensionId => 'ID'; + + @override + String get extensionError => 'Error'; + + @override + String get extensionCapabilities => 'Recursos'; + + @override + String get extensionMetadataProvider => 'Proveedor de metadatos'; + + @override + String get extensionDownloadProvider => 'Proveedor de descargas'; + + @override + String get extensionLyricsProvider => 'Proveedor de letras'; + + @override + String get extensionUrlHandler => 'Gestor de URL'; + + @override + String get extensionQualityOptions => 'Opciones de calidad'; + + @override + String get extensionPostProcessingHooks => 'Hooks post-procesamiento'; + + @override + String get extensionPermissions => 'Permisos'; + + @override + String get extensionSettings => 'Ajustes'; + + @override + String get extensionRemoveButton => 'Eliminar extensión'; + + @override + String get extensionUpdated => 'Actualizado'; + + @override + String get extensionMinAppVersion => 'Versión Mínima de la aplicación'; + + @override + String get extensionCustomTrackMatching => + 'Coincidencia de pista personalizada'; + + @override + String get extensionPostProcessing => 'Post-Procesamiento'; + + @override + String extensionHooksAvailable(int count) { + return '$count hook(s) disponibles'; + } + + @override + String extensionPatternsCount(int count) { + return 'Patrón(es) $count'; + } + + @override + String extensionStrategy(String strategy) { + return 'Estrategia: $strategy'; + } + + @override + String get extensionsProviderPrioritySection => 'Prioridad del proveedor'; + + @override + String get extensionsInstalledSection => 'Extensiones instaladas'; + + @override + String get extensionsNoExtensions => 'No hay extensiones instaladas'; + + @override + String get extensionsNoExtensionsSubtitle => + 'Instalar archivos .spotiflac-ext para añadir nuevos proveedores'; + + @override + String get extensionsInstallButton => 'Instalar extensión'; + + @override + String get extensionsInfoTip => + 'Las extensiones pueden añadir nuevos metadatos y proveedores de descargas. Sólo instalar extensiones desde fuentes confiables.'; + + @override + String get extensionsInstalledSuccess => 'Extensión instalada correctamente'; + + @override + String get extensionsDownloadPriority => 'Prioridad de descarga'; + + @override + String get extensionsDownloadPrioritySubtitle => + 'Establecer orden de servicio de descarga'; + + @override + String get extensionsNoDownloadProvider => + 'No hay extensiones con proveedor de descargas'; + + @override + String get extensionsMetadataPriority => 'Prioridad de los metadatos'; + + @override + String get extensionsMetadataPrioritySubtitle => + 'Establecer orden de búsqueda y metadatos'; + + @override + String get extensionsNoMetadataProvider => + 'No hay extensiones con el proveedor de metadatos'; + + @override + String get extensionsSearchProvider => 'Proveedor de búsqueda'; + + @override + String get extensionsNoCustomSearch => + 'No hay extensiones con búsqueda personalizada'; + + @override + String get extensionsSearchProviderDescription => + 'Elegir qué servicio usar para buscar pistas'; + + @override + String get extensionsCustomSearch => 'Búsqueda personalizada'; + + @override + String get extensionsErrorLoading => 'Error al cargar la extensión'; + + @override + String get qualityFlacLossless => 'FLAC Lossless'; + + @override + String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + + @override + String get qualityHiResFlac => 'Hi-Res FLAC'; + + @override + String get qualityHiResFlacSubtitle => '24 bits/hasta 96kHz'; + + @override + String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + + @override + String get qualityHiResFlacMaxSubtitle => '24 bits / hasta 192kHz'; + + @override + String get qualityNote => + 'La calidad real depende de la disponibilidad de la pista del servicio'; + + @override + String get downloadAskBeforeDownload => 'Preguntar antes de descargar'; + + @override + String get downloadDirectory => 'Carpeta de descarga'; + + @override + String get downloadSeparateSinglesFolder => 'Carpeta separada para pistas'; + + @override + String get downloadAlbumFolderStructure => 'Estructura de carpeta del álbum'; + + @override + String get downloadSaveFormat => 'Guardar Formato'; + + @override + String get downloadSelectService => 'Seleccionar Servicio'; + + @override + String get downloadSelectQuality => 'Seleccionar Calidad'; + + @override + String get downloadFrom => 'Descargar Desde'; + + @override + String get downloadDefaultQualityLabel => 'Calidad por Defecto'; + + @override + String get downloadBestAvailable => 'La mejor disponible'; + + @override + String get folderNone => 'Ninguna'; + + @override + String get folderNoneSubtitle => + 'Guardar todos los archivos directamente para descargar la carpeta'; + + @override + String get folderArtist => 'Artista'; + + @override + String get folderArtistSubtitle => 'Nombre del Artista/nombre de archivo'; + + @override + String get folderAlbum => 'Álbum'; + + @override + String get folderAlbumSubtitle => 'Nombre del álbum/nombre de archivo'; + + @override + String get folderArtistAlbum => 'Artista/Álbum'; + + @override + String get folderArtistAlbumSubtitle => + 'Nombre del Artista/Nombre del Álbum/Nombre del Archivo'; + + @override + String get serviceTidal => 'Tidal'; + + @override + String get serviceQobuz => 'Qobuz'; + + @override + String get serviceAmazon => 'Amazon'; + + @override + String get serviceDeezer => 'Deezer'; + + @override + String get serviceSpotify => 'Spotify'; + + @override + String get appearanceAmoledDark => 'AMOLED Oscuro'; + + @override + String get appearanceAmoledDarkSubtitle => 'Fondo negro puro'; + + @override + String get appearanceChooseAccentColor => 'Elegir color principal'; + + @override + String get appearanceChooseTheme => 'Modo de tema'; + + @override + String get queueTitle => 'Descargas en proceso'; + + @override + String get queueClearAll => 'Eliminar todo'; + + @override + String get queueClearAllMessage => + '¿Estás seguro de que quieres borrar todas las descargas?'; + + @override + String get queueEmpty => 'No hay descargas en cola'; + + @override + String get queueEmptySubtitle => 'Añadir pistas desde la pantalla de inicio'; + + @override + String get queueClearCompleted => 'Limpiar tareas finalizadas'; + + @override + String get queueDownloadFailed => 'Descarga fallida'; + + @override + String get queueTrackLabel => 'Pista:'; + + @override + String get queueArtistLabel => 'Artista:'; + + @override + String get queueErrorLabel => 'Error:'; + + @override + String get queueUnknownError => 'Error desconocido'; + + @override + String get albumFolderArtistAlbum => 'Artista / Álbum'; + + @override + String get albumFolderArtistAlbumSubtitle => + 'Álbumes/Nombre del Artista/Nombre del Álbum/'; + + @override + String get albumFolderArtistYearAlbum => 'Artista / [Año] Álbum'; + + @override + String get albumFolderArtistYearAlbumSubtitle => + 'Álbumes/Nombre del Artista /[2005] Nombre del Álbum/'; + + @override + String get albumFolderAlbumOnly => 'Sólo álbum'; + + @override + String get albumFolderAlbumOnlySubtitle => 'Álbumes/Nombre del Álbum/'; + + @override + String get albumFolderYearAlbum => 'Álbum [Año]'; + + @override + String get albumFolderYearAlbumSubtitle => 'Álbumes/[2005] Nombre del Álbum/'; + + @override + String get downloadedAlbumDeleteSelected => 'Borrar Seleccionados'; + + @override + String downloadedAlbumDeleteMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return '¿Eliminar $count $_temp0 del historial?\n\nEsto también eliminará los archivos del almacenamiento.'; + } + + @override + String get downloadedAlbumTracksHeader => 'Pistas'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count descargado'; + } + + @override + String downloadedAlbumSelectedCount(int count) { + return '$count seleccionado'; + } + + @override + String get downloadedAlbumAllSelected => 'Todas las pistas seleccionadas'; + + @override + String get downloadedAlbumTapToSelect => 'Toca las pistas para seleccionar'; + + @override + String downloadedAlbumDeleteCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'pistas', + one: 'pista', + ); + return '¡Eliminar $count $_temp0'; + } + + @override + String get downloadedAlbumSelectToDelete => 'Seleccionar pistas a eliminar'; + + @override + String get utilityFunctions => 'Funciones de utilidad'; + + @override + String get recentTypeArtist => 'Artista'; + + @override + String get recentTypeAlbum => 'Álbum'; + + @override + String get recentTypeSong => 'Canción'; + + @override + String get recentTypePlaylist => 'Lista de reproducción'; + + @override + String recentPlaylistInfo(String name) { + return 'Lista de reproducción: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } +} diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 7fe07412..77d394b8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -402,6 +402,9 @@ class AppLocalizationsFr extends AppLocalizations { String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Special Thanks'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index bfe0e9cf..640394a4 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -402,6 +402,9 @@ class AppLocalizationsHi extends AppLocalizations { String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Special Thanks'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 4be2cad8..a15135e6 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -406,6 +406,9 @@ class AppLocalizationsId extends AppLocalizations { String get aboutLogoArtist => 'Seniman berbakat yang membuat logo aplikasi kita yang indah!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Terima Kasih Khusus'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index a61062ba..e88e31e3 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -402,6 +402,9 @@ class AppLocalizationsJa extends AppLocalizations { String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'スペシャルサンクス'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index ef5b02cc..20ab8701 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -402,6 +402,9 @@ class AppLocalizationsKo extends AppLocalizations { String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Special Thanks'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index e5972c08..e91357a9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -402,6 +402,9 @@ class AppLocalizationsNl extends AppLocalizations { String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Special Thanks'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ed340a24..25a17d29 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -402,6 +402,9 @@ class AppLocalizationsPt extends AppLocalizations { String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Special Thanks'; @@ -2019,3 +2022,2019 @@ class AppLocalizationsPt extends AppLocalizations { return 'Error: $message'; } } + +/// The translations for Portuguese, as used in Portugal (`pt_PT`). +class AppLocalizationsPtPt extends AppLocalizationsPt { + AppLocalizationsPtPt() : super('pt_PT'); + + @override + String get appName => 'SpotiFLAC'; + + @override + String get appDescription => + 'Baixe faixas do Spotify em qualidade sem perdas de Tidal, Qobuz e Amazon Music.'; + + @override + String get navHome => 'Início'; + + @override + String get navHistory => 'Histórico'; + + @override + String get navSettings => 'Configurações'; + + @override + String get navStore => 'Loja'; + + @override + String get homeTitle => 'Início'; + + @override + String get homeSearchHint => 'Pesquise ou cole a URL do Spotify...'; + + @override + String homeSearchHintExtension(String extensionName) { + return 'Pesquisar com $extensionName...'; + } + + @override + String get homeSubtitle => 'Cole um link do Spotify ou procure por nome'; + + @override + String get homeSupports => + 'Suporte: Faixas, Álbuns, Playlists, URLs de Artista'; + + @override + String get homeRecent => 'Recentes'; + + @override + String get historyTitle => 'Histórico'; + + @override + String historyDownloading(int count) { + return 'Baixando ($count)'; + } + + @override + String get historyDownloaded => 'Baixados'; + + @override + String get historyFilterAll => 'Tudo'; + + @override + String get historyFilterAlbums => 'Álbuns'; + + @override + String get historyFilterSingles => 'Singles'; + + @override + String historyTracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count faixas', + one: '1 faixa', + ); + return '$_temp0'; + } + + @override + String historyAlbumsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count álbuns', + one: '1 álbum', + ); + return '$_temp0'; + } + + @override + String get historyNoDownloads => 'Nenhum histórico de downloads'; + + @override + String get historyNoDownloadsSubtitle => 'As faixas baixadas aparecerão aqui'; + + @override + String get historyNoAlbums => 'Sem álbuns baixados'; + + @override + String get historyNoAlbumsSubtitle => + 'Baixe várias faixas de um álbum para vê-las aqui'; + + @override + String get historyNoSingles => 'Sem singles baixados'; + + @override + String get historyNoSinglesSubtitle => + 'Os downloads de faixa individuais aparecerão aqui'; + + @override + String get settingsTitle => 'Configurações'; + + @override + String get settingsDownload => 'Download'; + + @override + String get settingsAppearance => 'Aparência'; + + @override + String get settingsOptions => 'Opções'; + + @override + String get settingsExtensions => 'Extensões'; + + @override + String get settingsAbout => 'Sobre'; + + @override + String get downloadTitle => 'Download'; + + @override + String get downloadLocation => 'Local dos Downloads'; + + @override + String get downloadLocationSubtitle => 'Escolha onde salvar os arquivos'; + + @override + String get downloadLocationDefault => 'Local padrão'; + + @override + String get downloadDefaultService => 'Serviço Padrão'; + + @override + String get downloadDefaultServiceSubtitle => 'Serviço usado para downloads'; + + @override + String get downloadDefaultQuality => 'Qualidade Predefinida'; + + @override + String get downloadAskQuality => 'Perguntar qualidade antes de baixar'; + + @override + String get downloadAskQualitySubtitle => + 'Mostrar seletor de qualidade para cada download'; + + @override + String get downloadFilenameFormat => 'Formato do Nome do Arquivo'; + + @override + String get downloadFolderOrganization => 'Organização de Pastas'; + + @override + String get downloadSeparateSingles => 'Separar Singles'; + + @override + String get downloadSeparateSinglesSubtitle => + 'Colocar singles numa pasta separada'; + + @override + String get qualityBest => 'Melhor Disponível'; + + @override + String get qualityFlac => 'FLAC'; + + @override + String get quality320 => '320 kbps'; + + @override + String get quality128 => '128 kbps'; + + @override + String get appearanceTitle => 'Aparência'; + + @override + String get appearanceTheme => 'Tema'; + + @override + String get appearanceThemeSystem => 'Sistema'; + + @override + String get appearanceThemeLight => 'Claro'; + + @override + String get appearanceThemeDark => 'Escuro'; + + @override + String get appearanceDynamicColor => 'Cores Dinâmicas'; + + @override + String get appearanceDynamicColorSubtitle => + 'Usar cores do seu papel de parede'; + + @override + String get appearanceAccentColor => 'Cor de Destaque'; + + @override + String get appearanceHistoryView => 'Visualização do Histórico'; + + @override + String get appearanceHistoryViewList => 'Lista'; + + @override + String get appearanceHistoryViewGrid => 'Grade'; + + @override + String get optionsTitle => 'Opções'; + + @override + String get optionsSearchSource => 'Origem da Pesquisa'; + + @override + String get optionsPrimaryProvider => 'Provedor Primário'; + + @override + String get optionsPrimaryProviderSubtitle => + 'Serviço usado ao pesquisar por nome da faixa.'; + + @override + String optionsUsingExtension(String extensionName) { + return 'Usando a extensão: $extensionName'; + } + + @override + String get optionsSwitchBack => + 'Toque no Deezer ou Spotify para alternar de volta da extensão'; + + @override + String get optionsAutoFallback => 'Fallback Automático'; + + @override + String get optionsAutoFallbackSubtitle => + 'Tentar outros serviços se o download falhar'; + + @override + String get optionsUseExtensionProviders => 'Usar Provedores de Extensão'; + + @override + String get optionsUseExtensionProvidersOn => + 'Extensões serão tentadas primeiro'; + + @override + String get optionsUseExtensionProvidersOff => + 'Usando apenas provedores integrados'; + + @override + String get optionsEmbedLyrics => 'Incorporar Letras'; + + @override + String get optionsEmbedLyricsSubtitle => + 'Incorporar letras sincronizadas aos arquivos FLAC'; + + @override + String get optionsMaxQualityCover => 'Capa de Qualidade Máxima'; + + @override + String get optionsMaxQualityCoverSubtitle => + 'Baixar capa do álbum com a mais alta resolução'; + + @override + String get optionsConcurrentDownloads => 'Downloads Simultâneos'; + + @override + String get optionsConcurrentSequential => 'Sequencial (1 por vez)'; + + @override + String optionsConcurrentParallel(int count) { + return '$count downloads paralelos'; + } + + @override + String get optionsConcurrentWarning => + 'Downloads simultâneos podem causar um limite da taxa (ratelimit)'; + + @override + String get optionsExtensionStore => 'Loja de Extensões'; + + @override + String get optionsExtensionStoreSubtitle => + 'Mostrar aba da Loja na navegação'; + + @override + String get optionsCheckUpdates => 'Procurar Atualizações'; + + @override + String get optionsCheckUpdatesSubtitle => + 'Notificar quando uma nova versão estiver disponível'; + + @override + String get optionsUpdateChannel => 'Canal de Atualização'; + + @override + String get optionsUpdateChannelStable => 'Somente versões estáveis'; + + @override + String get optionsUpdateChannelPreview => 'Obter versões de prévia'; + + @override + String get optionsUpdateChannelWarning => + 'A prévia pode conter erros ou recursos incompletos'; + + @override + String get optionsClearHistory => 'Limpar Histórico de Download'; + + @override + String get optionsClearHistorySubtitle => + 'Remover todas as faixas baixadas do histórico'; + + @override + String get optionsDetailedLogging => 'Registro detalhado'; + + @override + String get optionsDetailedLoggingOn => + 'Registros detalhados estão sendo gravados'; + + @override + String get optionsDetailedLoggingOff => 'Habilitar para relatórios de erros'; + + @override + String get optionsSpotifyCredentials => 'Credenciais do Spotify'; + + @override + String optionsSpotifyCredentialsConfigured(String clientId) { + return 'Client ID: $clientId...'; + } + + @override + String get optionsSpotifyCredentialsRequired => + 'Obrigatório - toque para configurar'; + + @override + String get optionsSpotifyWarning => + 'O Spotify requer as suas próprias credenciais de API. Consiga gratuitamente em developer.spotify.com'; + + @override + String get extensionsTitle => 'Extensões'; + + @override + String get extensionsInstalled => 'Extensões Instaladas'; + + @override + String get extensionsNone => 'Nenhuma extensão instalada'; + + @override + String get extensionsNoneSubtitle => + 'Instalar extensões a partir da aba Loja'; + + @override + String get extensionsEnabled => 'Habilitado'; + + @override + String get extensionsDisabled => 'Desabilitado'; + + @override + String extensionsVersion(String version) { + return 'Versão $version'; + } + + @override + String extensionsAuthor(String author) { + return 'por $author'; + } + + @override + String get extensionsUninstall => 'Desinstalar'; + + @override + String get extensionsSetAsSearch => 'Definir como Provedor de Pesquisa'; + + @override + String get storeTitle => 'Loja de Extensões'; + + @override + String get storeSearch => 'Pesquisar extensões...'; + + @override + String get storeInstall => 'Instalar'; + + @override + String get storeInstalled => 'Instalado'; + + @override + String get storeUpdate => 'Atualizar'; + + @override + String get aboutTitle => 'Sobre'; + + @override + String get aboutContributors => 'Colaboradores'; + + @override + String get aboutMobileDeveloper => 'Desenvolvedor da versão móvel'; + + @override + String get aboutOriginalCreator => 'Criador do SpotiFLAC original'; + + @override + String get aboutLogoArtist => + 'O artista talentoso que criou o nosso lindo logotipo do aplicativo!'; + + @override + String get aboutSpecialThanks => 'Agradecimentos Especiais'; + + @override + String get aboutLinks => 'Links'; + + @override + String get aboutMobileSource => 'Código-fonte do app móvel'; + + @override + String get aboutPCSource => 'Código-fonte do app desktop'; + + @override + String get aboutReportIssue => 'Reportar um problema'; + + @override + String get aboutReportIssueSubtitle => + 'Reporte qualquer problema que encontrar'; + + @override + String get aboutFeatureRequest => 'Solicitação de recurso'; + + @override + String get aboutFeatureRequestSubtitle => + 'Sugira novos recursos para o aplicativo'; + + @override + String get aboutSupport => 'Apoiar'; + + @override + String get aboutBuyMeCoffee => 'Compre-me um café'; + + @override + String get aboutBuyMeCoffeeSubtitle => 'Apoie o desenvolvimento na Ko-fi'; + + @override + String get aboutApp => 'Aplicativo'; + + @override + String get aboutVersion => 'Versão'; + + @override + String get aboutBinimumDesc => + 'O criador da API QQDL e HiFi. Sem esta API, os downloads Tidal não existiriam!'; + + @override + String get aboutSachinsenalDesc => + 'O criador original do projeto HiFi. A base da integração do Tidal!'; + + @override + String get aboutDoubleDouble => 'DoubleDouble'; + + @override + String get aboutDoubleDoubleDesc => + 'API incrível para downloads do Amazon Music. Obrigado por fazê-lo gratuitamente!'; + + @override + String get aboutDabMusic => 'DAB Music'; + + @override + String get aboutDabMusicDesc => + 'A melhor API de streaming do Qobuz. Downloads de alta resolução não seriam possíveis sem isso!'; + + @override + String get aboutAppDescription => + 'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.'; + + @override + String get albumTitle => 'Álbum'; + + @override + String albumTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count faixas', + one: '1 faixa', + ); + return '$_temp0'; + } + + @override + String get albumDownloadAll => 'Baixar Tudo'; + + @override + String get albumDownloadRemaining => 'Downloads Restantes'; + + @override + String get playlistTitle => 'Playlist'; + + @override + String get artistTitle => 'Artista'; + + @override + String get artistAlbums => 'Álbuns'; + + @override + String get artistSingles => 'Singles e EPs'; + + @override + String get artistCompilations => 'Compilações'; + + @override + String artistReleases(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count lançamentos', + one: '1 lançamento', + ); + return '$_temp0'; + } + + @override + String get artistPopular => 'Populares'; + + @override + String artistMonthlyListeners(String count) { + return '$count ouvintes mensais'; + } + + @override + String get trackMetadataTitle => 'Informações da Faixa'; + + @override + String get trackMetadataArtist => 'Artista'; + + @override + String get trackMetadataAlbum => 'Álbum'; + + @override + String get trackMetadataDuration => 'Duração'; + + @override + String get trackMetadataQuality => 'Qualidade'; + + @override + String get trackMetadataPath => 'Caminho do Arquivo'; + + @override + String get trackMetadataDownloadedAt => 'Baixado'; + + @override + String get trackMetadataService => 'Serviço'; + + @override + String get trackMetadataPlay => 'Reproduzir'; + + @override + String get trackMetadataShare => 'Compartilhar'; + + @override + String get trackMetadataDelete => 'Apagar'; + + @override + String get trackMetadataRedownload => 'Baixar Novamente'; + + @override + String get trackMetadataOpenFolder => 'Abrir Pasta'; + + @override + String get setupTitle => 'Bem-vindo ao SpotiFLAC'; + + @override + String get setupSubtitle => 'Vamos começar'; + + @override + String get setupStoragePermission => 'Permissão de Armazenamento'; + + @override + String get setupStoragePermissionSubtitle => + 'Necessária para salvar arquivos baixados'; + + @override + String get setupStoragePermissionGranted => 'Permissão concedida'; + + @override + String get setupStoragePermissionDenied => 'Permissão negada'; + + @override + String get setupGrantPermission => 'Conceder Permissão'; + + @override + String get setupDownloadLocation => 'Local do Download'; + + @override + String get setupChooseFolder => 'Selecionar Pasta'; + + @override + String get setupContinue => 'Continuar'; + + @override + String get setupSkip => 'Ignorar por enquanto'; + + @override + String get setupStorageAccessRequired => 'Acesso ao Armazenamento Necessário'; + + @override + String get setupStorageAccessMessage => + 'O SpotiFLAC precisa da permissão \"Acesso a todos os arquivos\" para salvar arquivos de música na sua pasta escolhida.'; + + @override + String get setupStorageAccessMessageAndroid11 => + 'O Android 11+ requer a permissão \"Acesso a Todos os Arquivos\" para salvar arquivos na pasta de download escolhida.'; + + @override + String get setupOpenSettings => 'Abrir Configurações'; + + @override + String get setupPermissionDeniedMessage => + 'Permissão negada. Por favor, conceda todas as permissões para continuar.'; + + @override + String setupPermissionRequired(String permissionType) { + return 'Permissão $permissionType Necessária'; + } + + @override + String setupPermissionRequiredMessage(String permissionType) { + return 'A permissão $permissionType é necessária para a melhor experiência. Você pode alterar isso mais tarde em Configurações.'; + } + + @override + String get setupSelectDownloadFolder => 'Escolher Pasta de Download'; + + @override + String get setupUseDefaultFolder => 'Usar Pasta Padrão?'; + + @override + String get setupNoFolderSelected => + 'Nenhuma pasta selecionada. Você gostaria de usar a pasta padrão de música?'; + + @override + String get setupUseDefault => 'Usar Padrão'; + + @override + String get setupDownloadLocationTitle => 'Local do Download'; + + @override + String get setupDownloadLocationIosMessage => + 'No iOS, downloads são salvos na pasta Documentos do aplicativo. Você pode acessá-los através do app Arquivos.'; + + @override + String get setupAppDocumentsFolder => 'Pasta Documentos do App'; + + @override + String get setupAppDocumentsFolderSubtitle => + 'Recomendado - acessível através do aplicativo Arquivos'; + + @override + String get setupChooseFromFiles => 'Escolher dos Arquivos'; + + @override + String get setupChooseFromFilesSubtitle => + 'Selecione o iCloud ou outro local'; + + @override + String get setupIosEmptyFolderWarning => + 'Limitação do iOS: Pastas vazias não podem ser selecionadas. Escolha uma pasta com pelo menos um arquivo.'; + + @override + String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + + @override + String get setupStepStorage => 'Storage'; + + @override + String get setupStepNotification => 'Notification'; + + @override + String get setupStepFolder => 'Folder'; + + @override + String get setupStepSpotify => 'Spotify'; + + @override + String get setupStepPermission => 'Permission'; + + @override + String get setupStorageGranted => 'Storage Permission Granted!'; + + @override + String get setupStorageRequired => 'Storage Permission Required'; + + @override + String get setupStorageDescription => + 'SpotiFLAC needs storage permission to save your downloaded music files.'; + + @override + String get setupNotificationGranted => 'Permissão de Notificações Concedida!'; + + @override + String get setupNotificationEnable => 'Habilitar Notificações'; + + @override + String get setupNotificationDescription => + 'Seja notificado quando os downloads completarem ou exigirem atenção.'; + + @override + String get setupFolderSelected => 'Pasta para Download Selecionada!'; + + @override + String get setupFolderChoose => 'Escolher Pasta de Download'; + + @override + String get setupFolderDescription => + 'Selecione uma pasta onde as suas músicas baixadas serão salvas.'; + + @override + String get setupChangeFolder => 'Alterar Pasta'; + + @override + String get setupSelectFolder => 'Seleccionar Pasta'; + + @override + String get setupSpotifyApiOptional => 'API do Spotify (opcional)'; + + @override + String get setupSpotifyApiDescription => + 'Adicione as suas credenciais da API do Spotify para obter melhores resultados de busca e acesso a conteúdo exclusivo do Spotify.'; + + @override + String get setupUseSpotifyApi => 'Usar API do Spotify'; + + @override + String get setupEnterCredentialsBelow => 'Insira as suas credenciais abaixo'; + + @override + String get setupUsingDeezer => 'Usando o Deezer (nenhuma conta necessária)'; + + @override + String get setupEnterClientId => 'Insira o Spotify Client ID'; + + @override + String get setupEnterClientSecret => 'Insira o Spotify Client Secret'; + + @override + String get setupGetFreeCredentials => + 'Receba as suas credenciais de API gratuitas na Spotify Developer Dashboard.'; + + @override + String get setupEnableNotifications => 'Habilitar Notificações'; + + @override + String get setupProceedToNextStep => + 'Você já pode prosseguir para o próximo passo.'; + + @override + String get setupNotificationProgressDescription => + 'Você receberá notificações de progresso dos downloads.'; + + @override + String get setupNotificationBackgroundDescription => + 'Seja notificado sobre o progresso e conclusão do download. Isso ajuda você a acompanhar os downloads quando o app estiver em segundo plano.'; + + @override + String get setupSkipForNow => 'Ignorar por enquanto'; + + @override + String get setupBack => 'Voltar'; + + @override + String get setupNext => 'Próximo'; + + @override + String get setupGetStarted => 'Começar'; + + @override + String get setupSkipAndStart => 'Ignorar e Iniciar'; + + @override + String get setupAllowAccessToManageFiles => + 'Por favor, habilite \"Permitir acesso para gerenciar todos os arquivos\" na próxima tela.'; + + @override + String get setupGetCredentialsFromSpotify => + 'Obter credenciais do developer.spotify.com'; + + @override + String get dialogCancel => 'Cancelar'; + + @override + String get dialogOk => 'OK'; + + @override + String get dialogSave => 'Salvar'; + + @override + String get dialogDelete => 'Apagar'; + + @override + String get dialogRetry => 'Tentar novamente'; + + @override + String get dialogClose => 'Fechar'; + + @override + String get dialogYes => 'Sim'; + + @override + String get dialogNo => 'Não'; + + @override + String get dialogClear => 'Limpar'; + + @override + String get dialogConfirm => 'Confirmar'; + + @override + String get dialogDone => 'Concluído'; + + @override + String get dialogImport => 'Importar'; + + @override + String get dialogDiscard => 'Descartar'; + + @override + String get dialogRemove => 'Remover'; + + @override + String get dialogUninstall => 'Desinstalar'; + + @override + String get dialogDiscardChanges => 'Descartar Alterações?'; + + @override + String get dialogUnsavedChanges => + 'Você tem alterações não salvas. Deseja descartá-las?'; + + @override + String get dialogDownloadFailed => 'Download Falhou'; + + @override + String get dialogTrackLabel => 'Faixa:'; + + @override + String get dialogArtistLabel => 'Artista:'; + + @override + String get dialogErrorLabel => 'Erro:'; + + @override + String get dialogClearAll => 'Limpar Tudo'; + + @override + String get dialogClearAllDownloads => + 'Você tem certeza que deseja limpar todos os downloads?'; + + @override + String get dialogRemoveFromDevice => 'Remove from device?'; + + @override + String get dialogRemoveExtension => 'Remove Extension'; + + @override + String get dialogRemoveExtensionMessage => + 'Are you sure you want to remove this extension? This cannot be undone.'; + + @override + String get dialogUninstallExtension => 'Uninstall Extension?'; + + @override + String dialogUninstallExtensionMessage(String extensionName) { + return 'Are you sure you want to remove $extensionName?'; + } + + @override + String get dialogClearHistoryTitle => 'Clear History'; + + @override + String get dialogClearHistoryMessage => + 'Are you sure you want to clear all download history? This cannot be undone.'; + + @override + String get dialogDeleteSelectedTitle => 'Delete Selected'; + + @override + String dialogDeleteSelectedMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + } + + @override + String get dialogImportPlaylistTitle => 'Import Playlist'; + + @override + String dialogImportPlaylistMessage(int count) { + return 'Found $count tracks in CSV. Add them to download queue?'; + } + + @override + String snackbarAddedToQueue(String trackName) { + return 'Added \"$trackName\" to queue'; + } + + @override + String snackbarAddedTracksToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String snackbarAlreadyDownloaded(String trackName) { + return '\"$trackName\" already downloaded'; + } + + @override + String get snackbarHistoryCleared => 'History cleared'; + + @override + String get snackbarCredentialsSaved => 'Credentials saved'; + + @override + String get snackbarCredentialsCleared => 'Credentials cleared'; + + @override + String snackbarDeletedTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Deleted $count $_temp0'; + } + + @override + String snackbarCannotOpenFile(String error) { + return 'Cannot open file: $error'; + } + + @override + String get snackbarFillAllFields => 'Please fill all fields'; + + @override + String get snackbarViewQueue => 'View Queue'; + + @override + String snackbarFailedToLoad(String error) { + return 'Failed to load: $error'; + } + + @override + String snackbarUrlCopied(String platform) { + return '$platform URL copied to clipboard'; + } + + @override + String get snackbarFileNotFound => 'File not found'; + + @override + String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + + @override + String get snackbarProviderPrioritySaved => 'Provider priority saved'; + + @override + String get snackbarMetadataProviderSaved => + 'Metadata provider priority saved'; + + @override + String snackbarExtensionInstalled(String extensionName) { + return '$extensionName installed.'; + } + + @override + String snackbarExtensionUpdated(String extensionName) { + return '$extensionName updated.'; + } + + @override + String get snackbarFailedToInstall => 'Failed to install extension'; + + @override + String get snackbarFailedToUpdate => 'Failed to update extension'; + + @override + String get errorRateLimited => 'Rate Limited'; + + @override + String get errorRateLimitedMessage => + 'Too many requests. Please wait a moment before searching again.'; + + @override + String errorFailedToLoad(String item) { + return 'Failed to load $item'; + } + + @override + String get errorNoTracksFound => 'No tracks found'; + + @override + String errorMissingExtensionSource(String item) { + return 'Cannot load $item: missing extension source'; + } + + @override + String get statusQueued => 'Queued'; + + @override + String get statusDownloading => 'Downloading'; + + @override + String get statusFinalizing => 'Finalizing'; + + @override + String get statusCompleted => 'Completed'; + + @override + String get statusFailed => 'Failed'; + + @override + String get statusSkipped => 'Ignorado'; + + @override + String get statusPaused => 'Pausado'; + + @override + String get actionPause => 'Pausar'; + + @override + String get actionResume => 'Retomar'; + + @override + String get actionCancel => 'Cancelar'; + + @override + String get actionStop => 'Parar'; + + @override + String get actionSelect => 'Selecionar'; + + @override + String get actionSelectAll => 'Selecionar Tudo'; + + @override + String get actionDeselect => 'Desselecionar'; + + @override + String get actionPaste => 'Colar'; + + @override + String get actionImportCsv => 'Importar CSV'; + + @override + String get actionRemoveCredentials => 'Remover Credenciais'; + + @override + String get actionSaveCredentials => 'Salvar Credenciais'; + + @override + String selectionSelected(int count) { + return '$count selecionado(s)'; + } + + @override + String get selectionAllSelected => 'Todas as faixas selecionadas'; + + @override + String get selectionTapToSelect => 'Toque nas faixas para selecionar'; + + @override + String selectionDeleteTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'faixas', + one: 'faixa', + ); + return 'Apagar $count $_temp0'; + } + + @override + String get selectionSelectToDelete => 'Selecione as faixas para apagar'; + + @override + String progressFetchingMetadata(int current, int total) { + return 'Buscando metadados... $current/$total'; + } + + @override + String get progressReadingCsv => 'Lendo CSV...'; + + @override + String get searchSongs => 'Músicas'; + + @override + String get searchArtists => 'Artistas'; + + @override + String get searchAlbums => 'Álbuns'; + + @override + String get searchPlaylists => 'Playlists'; + + @override + String get tooltipPlay => 'Reproduzir'; + + @override + String get tooltipCancel => 'Cancelar'; + + @override + String get tooltipStop => 'Parar'; + + @override + String get tooltipRetry => 'Tentar Novamente'; + + @override + String get tooltipRemove => 'Remover'; + + @override + String get tooltipClear => 'Limpar'; + + @override + String get tooltipPaste => 'Colar'; + + @override + String get filenameFormat => 'Formato do Nome do Arquivo'; + + @override + String filenameFormatPreview(String preview) { + return 'Prévia: $preview'; + } + + @override + String get filenameAvailablePlaceholders => 'Substituições permitidas:'; + + @override + String filenameHint(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get folderOrganization => 'Organização de Pastas'; + + @override + String get folderOrganizationNone => 'Nenhuma organização'; + + @override + String get folderOrganizationByArtist => 'Por Artista'; + + @override + String get folderOrganizationByAlbum => 'Por Album'; + + @override + String get folderOrganizationByArtistAlbum => 'Artista/Álbum'; + + @override + String get folderOrganizationDescription => + 'Organizar arquivos baixados em pastas'; + + @override + String get folderOrganizationNoneSubtitle => + 'Todos os arquivos na pasta de download'; + + @override + String get folderOrganizationByArtistSubtitle => + 'Pasta separada para cada artista'; + + @override + String get folderOrganizationByAlbumSubtitle => + 'Pasta separada para cada álbum'; + + @override + String get folderOrganizationByArtistAlbumSubtitle => + 'Pastas aninhadas para artista e álbum'; + + @override + String get updateAvailable => 'Atualização Disponível'; + + @override + String updateNewVersion(String version) { + return 'A versão $version está disponível'; + } + + @override + String get updateDownload => 'Baixar'; + + @override + String get updateLater => 'Depois'; + + @override + String get updateChangelog => 'Lista de alterações'; + + @override + String get updateStartingDownload => 'Iniciando download...'; + + @override + String get updateDownloadFailed => 'Download falhou'; + + @override + String get updateFailedMessage => 'Falha ao baixar a atualização'; + + @override + String get updateNewVersionReady => 'Uma nova versão está pronta'; + + @override + String get updateCurrent => 'Atual'; + + @override + String get updateNew => 'Novo'; + + @override + String get updateDownloading => 'Baixando...'; + + @override + String get updateWhatsNew => 'Novidades'; + + @override + String get updateDownloadInstall => 'Baixar e Instalar'; + + @override + String get updateDontRemind => 'Não lembrar'; + + @override + String get providerPriority => 'Prioridade de Provedor'; + + @override + String get providerPrioritySubtitle => + 'Arraste para reordenar os provedores de download'; + + @override + String get providerPriorityTitle => 'Prioridade de Provedor'; + + @override + String get providerPriorityDescription => + 'Arraste para reordenar provedores de download. O aplicativo irá tentar provedores de cima para baixo ao baixar as faixas.'; + + @override + String get providerPriorityInfo => + 'Se uma faixa não estiver disponível no primeiro provedor, o aplicativo irá tentar automaticamente a próxima.'; + + @override + String get providerBuiltIn => 'Embutido'; + + @override + String get providerExtension => 'Extensão'; + + @override + String get metadataProviderPriority => 'Prioridade de Provedor de Metadados'; + + @override + String get metadataProviderPrioritySubtitle => + 'Ordem usada para obter metadados de faixa'; + + @override + String get metadataProviderPriorityTitle => 'Prioridade de Metadados'; + + @override + String get metadataProviderPriorityDescription => + 'Arraste para reordenar provedores de metadados. O aplicativo tentará provedores de cima para baixo ao procurar por faixas e buscar metadados.'; + + @override + String get metadataProviderPriorityInfo => + 'O Deezer não tem limites de taxa e é recomendado como principal. O Spotify pode limitar a taxa após muitas solicitações.'; + + @override + String get metadataNoRateLimits => 'Sem limites de taxa'; + + @override + String get metadataMayRateLimit => 'Pode ter limites de taxa'; + + @override + String get logTitle => 'Registros'; + + @override + String get logCopy => 'Copiar Registros'; + + @override + String get logClear => 'Limpar Registros'; + + @override + String get logShare => 'Compartilhar Registros'; + + @override + String get logEmpty => 'Ainda não há registros'; + + @override + String get logCopied => 'Registros copiados para área de transferência'; + + @override + String get logSearchHint => 'Pesquisar registros...'; + + @override + String get logFilterLevel => 'Nível'; + + @override + String get logFilterSection => 'Filtro'; + + @override + String get logShareLogs => 'Compartilhar registros'; + + @override + String get logClearLogs => 'Limpar registros'; + + @override + String get logClearLogsTitle => 'Limpar Registros'; + + @override + String get logClearLogsMessage => + 'Tem certeza de que deseja limpar todos os registros?'; + + @override + String get logIspBlocking => 'BLOQUEIO DE ISP DETECTADO'; + + @override + String get logRateLimited => 'TAXA LIMITADA (RATELIMITED)'; + + @override + String get logNetworkError => 'ERRO DE REDE'; + + @override + String get logTrackNotFound => 'FAIXA NÃO ENCONTRADA'; + + @override + String get logFilterBySeverity => 'Filtrar registros por gravidade'; + + @override + String get logNoLogsYet => 'Ainda não há registros'; + + @override + String get logNoLogsYetSubtitle => + 'Os registros aparecerão aqui enquanto você usa o aplicativo'; + + @override + String get logIssueSummary => 'Resumo do Problemas'; + + @override + String get logIspBlockingDescription => + 'O seu provedor pode estar bloqueando o acesso aos serviços de download'; + + @override + String get logIspBlockingSuggestion => + 'Tente usar uma VPN ou altere o DNS para 1.1.1 ou 8.8.8.8'; + + @override + String get logRateLimitedDescription => 'Muitas solicitações ao serviço'; + + @override + String get logRateLimitedSuggestion => + 'Aguarde alguns minutos antes de tentar novamente'; + + @override + String get logNetworkErrorDescription => 'Problemas de conexão detectados'; + + @override + String get logNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String logTotalErrors(int count) { + return 'Total errors: $count'; + } + + @override + String logAffected(String domains) { + return 'Affected: $domains'; + } + + @override + String logEntriesFiltered(int count) { + return 'Entries ($count filtered)'; + } + + @override + String logEntries(int count) { + return 'Entries ($count)'; + } + + @override + String get credentialsTitle => 'Spotify Credentials'; + + @override + String get credentialsDescription => + 'Enter your Client ID and Secret to use your own Spotify application quota.'; + + @override + String get credentialsClientId => 'Client ID'; + + @override + String get credentialsClientIdHint => 'Colar Client ID'; + + @override + String get credentialsClientSecret => 'Client Secret'; + + @override + String get credentialsClientSecretHint => 'Colar Client Secret'; + + @override + String get channelStable => 'Estável'; + + @override + String get channelPreview => 'Prévia'; + + @override + String get sectionSearchSource => 'Origem da Pesquisa'; + + @override + String get sectionDownload => 'Download'; + + @override + String get sectionPerformance => 'Desempenho'; + + @override + String get sectionApp => 'Aplicativo'; + + @override + String get sectionData => 'Dados'; + + @override + String get sectionDebug => 'Depuração'; + + @override + String get sectionService => 'Serviço'; + + @override + String get sectionAudioQuality => 'Qualidade de Áudio'; + + @override + String get sectionFileSettings => 'Configurações de Arquivo'; + + @override + String get sectionColor => 'Cor'; + + @override + String get sectionTheme => 'Tema'; + + @override + String get sectionLayout => 'Layout'; + + @override + String get sectionLanguage => 'Idioma'; + + @override + String get appearanceLanguage => 'Idioma do aplicativo'; + + @override + String get appearanceLanguageSubtitle => 'Escolha o seu idioma preferido'; + + @override + String get settingsAppearanceSubtitle => 'Tema, cores, exibição'; + + @override + String get settingsDownloadSubtitle => + 'Serviço, qualidade, formato de nome de arquivo'; + + @override + String get settingsOptionsSubtitle => + 'Fallback, letras, arte de capa, atualizações'; + + @override + String get settingsExtensionsSubtitle => 'Gerenciar provedores de download'; + + @override + String get settingsLogsSubtitle => 'Ver logs do app para depuração'; + + @override + String get loadingSharedLink => 'Carregando link compartilhado...'; + + @override + String get pressBackAgainToExit => 'Pressione voltar novamente para sair'; + + @override + String get tracksHeader => 'Faixas'; + + @override + String downloadAllCount(int count) { + return 'Baixar Todos ($count)'; + } + + @override + String tracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count faixas', + one: '1 faixa', + ); + return '$_temp0'; + } + + @override + String get trackCopyFilePath => 'Copiar caminho do arquivo'; + + @override + String get trackRemoveFromDevice => 'Remover do dispositivo'; + + @override + String get trackLoadLyrics => 'Carregar Letras'; + + @override + String get trackMetadata => 'Metadados'; + + @override + String get trackFileInfo => 'Informações do Arquivo'; + + @override + String get trackLyrics => 'Letras'; + + @override + String get trackFileNotFound => 'Arquivo não encontrado'; + + @override + String get trackOpenInDeezer => 'Abrir no Deezer'; + + @override + String get trackOpenInSpotify => 'Abrir no Spotify'; + + @override + String get trackTrackName => 'Nome da faixa'; + + @override + String get trackArtist => 'Artista'; + + @override + String get trackAlbumArtist => 'Artista do álbum'; + + @override + String get trackAlbum => 'Álbum'; + + @override + String get trackTrackNumber => 'Número da faixa'; + + @override + String get trackDiscNumber => 'Número do disco'; + + @override + String get trackDuration => 'Duração'; + + @override + String get trackAudioQuality => 'Qualidade de Áudio'; + + @override + String get trackReleaseDate => 'Data de lançamento'; + + @override + String get trackDownloaded => 'Baixado'; + + @override + String get trackCopyLyrics => 'Copy lyrics'; + + @override + String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + + @override + String get trackLyricsTimeout => 'Request timed out. Try again later.'; + + @override + String get trackLyricsLoadFailed => 'Failed to load lyrics'; + + @override + String get trackCopiedToClipboard => 'Copied to clipboard'; + + @override + String get trackDeleteConfirmTitle => 'Remove from device?'; + + @override + String get trackDeleteConfirmMessage => + 'This will permanently delete the downloaded file and remove it from your history.'; + + @override + String trackCannotOpen(String message) { + return 'Cannot open: $message'; + } + + @override + String get dateToday => 'Today'; + + @override + String get dateYesterday => 'Yesterday'; + + @override + String dateDaysAgo(int count) { + return '$count days ago'; + } + + @override + String dateWeeksAgo(int count) { + return '$count weeks ago'; + } + + @override + String dateMonthsAgo(int count) { + return '$count months ago'; + } + + @override + String get concurrentSequential => 'Sequential'; + + @override + String get concurrentParallel2 => '2 Parallel'; + + @override + String get concurrentParallel3 => '3 Parallel'; + + @override + String get tapToSeeError => 'Tap to see error details'; + + @override + String get storeFilterAll => 'All'; + + @override + String get storeFilterMetadata => 'Metadata'; + + @override + String get storeFilterDownload => 'Download'; + + @override + String get storeFilterUtility => 'Utility'; + + @override + String get storeFilterLyrics => 'Lyrics'; + + @override + String get storeFilterIntegration => 'Integration'; + + @override + String get storeClearFilters => 'Clear filters'; + + @override + String get storeNoResults => 'No extensions found'; + + @override + String get extensionProviderPriority => 'Provider Priority'; + + @override + String get extensionInstallButton => 'Install Extension'; + + @override + String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Use built-in search'; + + @override + String get extensionAuthor => 'Author'; + + @override + String get extensionId => 'ID'; + + @override + String get extensionError => 'Error'; + + @override + String get extensionCapabilities => 'Capabilities'; + + @override + String get extensionMetadataProvider => 'Metadata Provider'; + + @override + String get extensionDownloadProvider => 'Download Provider'; + + @override + String get extensionLyricsProvider => 'Lyrics Provider'; + + @override + String get extensionUrlHandler => 'URL Handler'; + + @override + String get extensionQualityOptions => 'Quality Options'; + + @override + String get extensionPostProcessingHooks => 'Post-Processing Hooks'; + + @override + String get extensionPermissions => 'Permissions'; + + @override + String get extensionSettings => 'Settings'; + + @override + String get extensionRemoveButton => 'Remover Extensão'; + + @override + String get extensionUpdated => 'Atualizado'; + + @override + String get extensionMinAppVersion => 'Versão Mínima do App'; + + @override + String get extensionCustomTrackMatching => + 'Correspondência de Faixa Personalizada'; + + @override + String get extensionPostProcessing => 'Pós-Processamento'; + + @override + String extensionHooksAvailable(int count) { + return '$count gancho(s) disponíveis'; + } + + @override + String extensionPatternsCount(int count) { + return '$count padrão(ões)'; + } + + @override + String extensionStrategy(String strategy) { + return 'Estratégia: $strategy'; + } + + @override + String get extensionsProviderPrioritySection => 'Prioridade de Provedor'; + + @override + String get extensionsInstalledSection => 'Extensões Instaladas'; + + @override + String get extensionsNoExtensions => 'Nenhuma extensão instalada'; + + @override + String get extensionsNoExtensionsSubtitle => + 'Instale arquivos .spotiflac-ext para adicionar novos provedores'; + + @override + String get extensionsInstallButton => 'Instalar Extensão'; + + @override + String get extensionsInfoTip => + 'Extensões podem adicionar novos metadados e baixar provedores. Somente instale extensões a partir de fontes confiáveis.'; + + @override + String get extensionsInstalledSuccess => 'Extensão instalada com sucesso'; + + @override + String get extensionsDownloadPriority => 'Prioridade de Download'; + + @override + String get extensionsDownloadPrioritySubtitle => + 'Definir ordem do serviço de download'; + + @override + String get extensionsNoDownloadProvider => + 'Nenhuma extensão com provedor de download'; + + @override + String get extensionsMetadataPriority => 'Prioridade de Metadados'; + + @override + String get extensionsMetadataPrioritySubtitle => + 'Definir ordem de origem de pesquisa e metadados'; + + @override + String get extensionsNoMetadataProvider => + 'Nenhuma extensão com provedor de metadados'; + + @override + String get extensionsSearchProvider => 'Provedor de Pesquisa'; + + @override + String get extensionsNoCustomSearch => + 'Nenhuma extensão com pesquisa personalizada'; + + @override + String get extensionsSearchProviderDescription => + 'Escolha qual serviço utilizar para pesquisar faixas'; + + @override + String get extensionsCustomSearch => 'Busca personalizada'; + + @override + String get extensionsErrorLoading => 'Erro ao carregar extensão'; + + @override + String get qualityFlacLossless => 'FLAC Lossless'; + + @override + String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + + @override + String get qualityHiResFlac => 'Hi-Res FLAC'; + + @override + String get qualityHiResFlacSubtitle => '24-bit / até 96kHz'; + + @override + String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + + @override + String get qualityHiResFlacMaxSubtitle => '24-bit / até 192kHz'; + + @override + String get qualityNote => + 'A qualidade real depende da faixa que estiver disponível no serviço'; + + @override + String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar'; + + @override + String get downloadDirectory => 'Pasta de Download'; + + @override + String get downloadSeparateSinglesFolder => 'Pasta de Singles Separada'; + + @override + String get downloadAlbumFolderStructure => 'Estrutura da Pasta de Álbum'; + + @override + String get downloadSaveFormat => 'Formato para Salvar'; + + @override + String get downloadSelectService => 'Selecionar Serviço'; + + @override + String get downloadSelectQuality => 'Selecionar Qualidade'; + + @override + String get downloadFrom => 'Baixar De'; + + @override + String get downloadDefaultQualityLabel => 'Qualidade Padrão'; + + @override + String get downloadBestAvailable => 'Melhor Disponível'; + + @override + String get folderNone => 'Nenhum'; + + @override + String get folderNoneSubtitle => 'Save all files directly to download folder'; + + @override + String get folderArtist => 'Artist'; + + @override + String get folderArtistSubtitle => 'Artist Name/filename'; + + @override + String get folderAlbum => 'Album'; + + @override + String get folderAlbumSubtitle => 'Album Name/filename'; + + @override + String get folderArtistAlbum => 'Artist/Album'; + + @override + String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; + + @override + String get serviceTidal => 'Tidal'; + + @override + String get serviceQobuz => 'Qobuz'; + + @override + String get serviceAmazon => 'Amazon'; + + @override + String get serviceDeezer => 'Deezer'; + + @override + String get serviceSpotify => 'Spotify'; + + @override + String get appearanceAmoledDark => 'AMOLED Dark'; + + @override + String get appearanceAmoledDarkSubtitle => 'Pure black background'; + + @override + String get appearanceChooseAccentColor => 'Choose Accent Color'; + + @override + String get appearanceChooseTheme => 'Theme Mode'; + + @override + String get queueTitle => 'Download Queue'; + + @override + String get queueClearAll => 'Clear All'; + + @override + String get queueClearAllMessage => + 'Are you sure you want to clear all downloads?'; + + @override + String get queueEmpty => 'No downloads in queue'; + + @override + String get queueEmptySubtitle => 'Add tracks from the home screen'; + + @override + String get queueClearCompleted => 'Clear completed'; + + @override + String get queueDownloadFailed => 'Download Failed'; + + @override + String get queueTrackLabel => 'Track:'; + + @override + String get queueArtistLabel => 'Artist:'; + + @override + String get queueErrorLabel => 'Error:'; + + @override + String get queueUnknownError => 'Unknown error'; + + @override + String get albumFolderArtistAlbum => 'Artist / Album'; + + @override + String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; + + @override + String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; + + @override + String get albumFolderArtistYearAlbumSubtitle => + 'Albums/Artist Name/[2005] Album Name/'; + + @override + String get albumFolderAlbumOnly => 'Album Only'; + + @override + String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + + @override + String get albumFolderYearAlbum => '[Year] Album'; + + @override + String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + + @override + String get downloadedAlbumDeleteSelected => 'Delete Selected'; + + @override + String downloadedAlbumDeleteMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + } + + @override + String get downloadedAlbumTracksHeader => 'Tracks'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String downloadedAlbumSelectedCount(int count) { + return '$count selected'; + } + + @override + String get downloadedAlbumAllSelected => 'All tracks selected'; + + @override + String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + + @override + String downloadedAlbumDeleteCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + + @override + String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index eab39f44..7aa28243 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -414,6 +414,9 @@ class AppLocalizationsRu extends AppLocalizations { String get aboutLogoArtist => 'Талантливый художник, который создал наш красивый логотип приложения!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Особая благодарность'; @@ -1533,7 +1536,7 @@ class AppLocalizationsRu extends AppLocalizations { String get trackFileInfo => 'Информация о файле'; @override - String get trackLyrics => 'Тексты песен'; + String get trackLyrics => 'Текст песни'; @override String get trackFileNotFound => 'Файл не найден'; @@ -1545,7 +1548,7 @@ class AppLocalizationsRu extends AppLocalizations { String get trackOpenInSpotify => 'Открыть в Spotify'; @override - String get trackTrackName => 'Название трека'; + String get trackTrackName => 'Название'; @override String get trackArtist => 'Исполнитель'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e949682c..ac47c5fa 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -402,6 +402,9 @@ class AppLocalizationsZh extends AppLocalizations { String get aboutLogoArtist => 'The talented artist who created our beautiful app logo!'; + @override + String get aboutTranslators => 'Translators'; + @override String get aboutSpecialThanks => 'Special Thanks'; @@ -4056,7 +4059,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; @override - String get homeRecent => 'Recent'; + String get homeRecent => '最新的'; @override String get historyTitle => 'History'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5fa657bc..d54ab75d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -290,6 +290,8 @@ "@aboutOriginalCreator": {"description": "Role description for original creator"}, "aboutLogoArtist": "The talented artist who created our beautiful app logo!", "@aboutLogoArtist": {"description": "Role description for logo artist"}, + "aboutTranslators": "Translators", + "@aboutTranslators": {"description": "Section for translators"}, "aboutSpecialThanks": "Special Thanks", "@aboutSpecialThanks": {"description": "Section for special thanks"}, "aboutLinks": "Links", diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index c5d40717..b451b4a5 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -1,5 +1,5 @@ { - "@@locale": "es-ES", + "@@locale": "es_ES", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index b2703f94..cc4dbc02 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -1,5 +1,5 @@ { - "@@locale": "pt-PT", + "@@locale": "pt_PT", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index 7e545a0b..5b9f70aa 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh-CN", + "@@locale": "zh_CN", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 3798a905..57beac71 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -1,5 +1,5 @@ { - "@@locale": "zh-TW", + "@@locale": "zh_TW", "@@last_modified": "2026-01-16", "appName": "SpotiFLAC", "@appName": { diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 83e2f401..25e15d38 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -86,6 +86,13 @@ class AboutPage extends StatelessWidget { ), ), + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.aboutTranslators), + ), + const SliverToBoxAdapter( + child: _TranslatorsSection(), + ), + SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks), ), @@ -395,7 +402,149 @@ class _ContributorItem extends StatelessWidget { } } -/// Settings item with 40x40 icon area to align with contributor avatars +/// Translator data model +class _Translator { + final String name; + final String githubUsername; + final String language; + final String flag; + + const _Translator({ + required this.name, + required this.githubUsername, + required this.language, + required this.flag, + }); +} + +/// Translators section with compact chip-style layout +class _TranslatorsSection extends StatelessWidget { + const _TranslatorsSection(); + + static const List<_Translator> _translators = [ + _Translator( + name: 'Pedro Marcondes', + githubUsername: 'justapedro', + language: 'Portuguese', + flag: '🇵🇹', + ), + _Translator( + name: 'Credits 125', + githubUsername: 'credits125', + language: 'Spanish', + flag: '🇪🇸', + ), + _Translator( + name: 'Владислав', + githubUsername: 'OdiNoKiY_KoT', + language: 'Russian', + flag: '🇷🇺', + ), + _Translator( + name: 'Max', + githubUsername: 'Amonoman', + language: 'German', + flag: '🇩🇪', + ), + ]; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final cardColor = isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + : colorScheme.surfaceContainerHighest; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _translators.map((translator) => _TranslatorChip( + translator: translator, + )).toList(), + ), + ), + ); + } +} + +/// Individual translator chip +class _TranslatorChip extends StatelessWidget { + final _Translator translator; + + const _TranslatorChip({required this.translator}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Material( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: () => _launchGitHub(translator.githubUsername), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImage( + imageUrl: 'https://github.com/${translator.githubUsername}.png', + width: 20, + height: 20, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 20, + height: 20, + color: colorScheme.surface, + child: Icon(Icons.person, size: 12, color: colorScheme.onSurfaceVariant), + ), + errorWidget: (context, url, error) => Container( + width: 20, + height: 20, + color: colorScheme.surface, + child: Icon(Icons.person, size: 12, color: colorScheme.onSurfaceVariant), + ), + ), + ), + const SizedBox(width: 8), + Text( + translator.name, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 6), + Text( + translator.flag, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + } + + Future _launchGitHub(String username) async { + final uri = Uri.parse('https://github.com/$username'); + await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + } +} + class _AboutSettingsItem extends StatelessWidget { final IconData icon; final String title; From 606e7c107930d6025dd0af9d114bf3635eec6992 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 02:28:35 +0700 Subject: [PATCH 33/48] fix: change translator links from GitHub to Crowdin profiles --- lib/screens/settings/about_page.dart | 45 +++++++++++----------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 25e15d38..2840df6a 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -405,13 +405,13 @@ class _ContributorItem extends StatelessWidget { /// Translator data model class _Translator { final String name; - final String githubUsername; + final String crowdinUsername; final String language; final String flag; const _Translator({ required this.name, - required this.githubUsername, + required this.crowdinUsername, required this.language, required this.flag, }); @@ -424,25 +424,25 @@ class _TranslatorsSection extends StatelessWidget { static const List<_Translator> _translators = [ _Translator( name: 'Pedro Marcondes', - githubUsername: 'justapedro', + crowdinUsername: 'justapedro', language: 'Portuguese', flag: '🇵🇹', ), _Translator( name: 'Credits 125', - githubUsername: 'credits125', + crowdinUsername: 'credits125', language: 'Spanish', flag: '🇪🇸', ), _Translator( name: 'Владислав', - githubUsername: 'OdiNoKiY_KoT', + crowdinUsername: 'odinokiy_kot', language: 'Russian', flag: '🇷🇺', ), _Translator( name: 'Max', - githubUsername: 'Amonoman', + crowdinUsername: 'amonoman', language: 'German', flag: '🇩🇪', ), @@ -491,31 +491,22 @@ class _TranslatorChip extends StatelessWidget { color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20), child: InkWell( - onTap: () => _launchGitHub(translator.githubUsername), + onTap: () => _launchCrowdin(translator.crowdinUsername), borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - imageUrl: 'https://github.com/${translator.githubUsername}.png', - width: 20, - height: 20, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - width: 20, - height: 20, - color: colorScheme.surface, - child: Icon(Icons.person, size: 12, color: colorScheme.onSurfaceVariant), - ), - errorWidget: (context, url, error) => Container( - width: 20, - height: 20, - color: colorScheme.surface, - child: Icon(Icons.person, size: 12, color: colorScheme.onSurfaceVariant), + CircleAvatar( + radius: 10, + backgroundColor: colorScheme.primary.withValues(alpha: 0.2), + child: Text( + translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: colorScheme.primary, ), ), ), @@ -539,8 +530,8 @@ class _TranslatorChip extends StatelessWidget { ); } - Future _launchGitHub(String username) async { - final uri = Uri.parse('https://github.com/$username'); + Future _launchCrowdin(String username) async { + final uri = Uri.parse('https://crowdin.com/profile/$username'); await launchUrl(uri, mode: LaunchMode.inAppBrowserView); } } From d143b82068a6fe8264d8cd3372cf700c68123f9e Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 02:33:12 +0700 Subject: [PATCH 34/48] fix: add es_ES and pt_PT locale codes to language selector --- lib/screens/settings/appearance_settings_page.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 1f467c8d..e8c3b023 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -694,20 +694,23 @@ class _LanguageSelector extends StatelessWidget { required this.onChanged, }); - static const _allLanguages = [ +static const _allLanguages = [ ('system', 'System Default', Icons.phone_android), ('en', 'English', Icons.language), ('id', 'Bahasa Indonesia', Icons.language), ('de', 'Deutsch', Icons.language), ('es', 'Español', Icons.language), + ('es_ES', 'Español (España)', Icons.language), ('fr', 'Français', Icons.language), ('hi', 'हिन्दी', Icons.language), ('ja', '日本語', Icons.language), ('ko', '한국어', Icons.language), ('nl', 'Nederlands', Icons.language), ('pt', 'Português', Icons.language), + ('pt_PT', 'Português (Portugal)', Icons.language), ('ru', 'Русский', Icons.language), ('zh', '简体中文', Icons.language), + ('zh_CN', '简体中文 (中国)', Icons.language), ('zh_TW', '繁體中文', Icons.language), ]; From 7749399239e2e2990a821defa006e38ebf5e98ad Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 02:35:00 +0700 Subject: [PATCH 35/48] docs: add translator credits to changelog --- CHANGELOG.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e66352..37e62052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,13 @@ ### Added - **New Languages**: Added Spanish (es) and Portuguese (pt) translations + - Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125)) + - Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro)) + - Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot)) -- **Quick Search Provider Switcher**: Dropdown menu in search bar for instant provider switching +- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching - Tap the search icon to reveal a dropdown menu with all available search providers - - Shows default provider (Deezer/Spotify based on metadata source setting) at the top + - Shows default provider (Deezer based on metadata source setting) at the top - Lists all enabled extensions with custom search capability - Displays extension icons when available - Checkmark indicates currently selected provider @@ -16,13 +19,18 @@ - Re-triggers search automatically if there's existing text in the search bar - Eliminates need to navigate to Settings > Extensions > Search Provider -- **Genre & Label Metadata**: Downloaded tracks now include genre and record label information +- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions + - Extensions can define `button` type in manifest settings + - Triggers JavaScript function when tapped (e.g., start OAuth flow) + - Useful for authentication, manual sync, or any custom action + +- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information - Fetches genre and label from Deezer album API for each track - Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files - Works automatically when Deezer track ID is available (via ISRC matching) - Supports all download services (Tidal, Qobuz, Amazon) and extension downloads -- **MP3 Quality Option**: Optional MP3 download format with FLAC-to-MP3 conversion +- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion - New "Enable MP3 Option" toggle in Settings > Download > Audio Quality - When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options - Available in both the quality picker dialog and default quality settings @@ -44,7 +52,7 @@ - Larger shadow and rounded corners (20px radius) - Higher resolution cover caching -- **Spotify-style Sticky Title**: Title appears in AppBar when scrolling past the info card +- **Sticky Title**: Title appears in AppBar when scrolling past the info card - Smooth fade-in animation (200ms) when scrolling down - Title hidden when header is expanded (shows in info card instead) - AppBar uses theme color (surface) for clean, native look @@ -54,13 +62,13 @@ - Extracted from first track's artist metadata - Styled with `onSurfaceVariant` color for visual hierarchy -- **Disc Separation for Multi-Disc Albums**: Downloaded albums with multiple discs now display tracks grouped by disc +- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc - Visual disc separator header showing "Disc 1", "Disc 2", etc. - Tracks sorted by disc number first, then by track number - Single-disc albums display normally without separators - Fixes confusion when albums have duplicate track numbers across discs -- **Album Grouping in Recents**: Downloads now show as albums instead of individual tracks in the Recent section +- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section - Prevents flooding the recents list when downloading full albums - Groups tracks by album name and artist - Tapping navigates directly to the downloaded album screen @@ -76,7 +84,7 @@ - **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed - Dark theme: Black background with white text - Light theme: White background with black text - - Matches Spotify's behavior for better readability + - Matches modern app behavior for better readability ### Fixed From 61720f3f2a95a490d2388d2cb26ff1e0596dca02 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 02:55:39 +0700 Subject: [PATCH 36/48] chore(ios): sync FFmpeg service and add palette_generator dependency --- build_assets/ffmpeg_service_ios.dart | 141 +++++++++++++++++++++++++-- pubspec_ios.yaml | 1 + 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/build_assets/ffmpeg_service_ios.dart b/build_assets/ffmpeg_service_ios.dart index b386490a..da0543ce 100644 --- a/build_assets/ffmpeg_service_ios.dart +++ b/build_assets/ffmpeg_service_ios.dart @@ -42,17 +42,27 @@ class FFmpegServiceIOS { } /// Convert FLAC to MP3 - static Future convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async { - final dir = File(inputPath).parent.path; - final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', ''); - final outputDir = '$dir${Platform.pathSeparator}MP3'; - await Directory(outputDir).create(recursive: true); - final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3'; + /// If deleteOriginal is true, deletes the FLAC file after conversion + static Future convertFlacToMp3( + String inputPath, { + String bitrate = '320k', + bool deleteOriginal = true, + }) async { + // Convert in same folder, just change extension + final outputPath = inputPath.replaceAll('.flac', '.mp3'); final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y'; final result = await _execute(command); - if (result.success) return outputPath; + if (result.success) { + // Delete original FLAC if requested + if (deleteOriginal) { + try { + await File(inputPath).delete(); + } catch (_) {} + } + return outputPath; + } _log.e('FLAC to MP3 conversion failed: ${result.output}'); return null; } @@ -177,6 +187,123 @@ class FFmpegServiceIOS { return null; } + /// Embed metadata and cover art to MP3 file using ID3v2 tags + /// Returns the file path on success, null on failure + static Future embedMetadataToMp3({ + required String mp3Path, + String? coverPath, + Map? metadata, + }) async { + final tempOutput = '$mp3Path.tmp'; + + final StringBuffer cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$mp3Path" '); + + if (coverPath != null) { + cmdBuffer.write('-i "$coverPath" '); + } + + cmdBuffer.write('-map 0:a '); + + if (coverPath != null) { + cmdBuffer.write('-map 1:0 '); + cmdBuffer.write('-c:v:0 copy '); + cmdBuffer.write('-id3v2_version 3 '); + cmdBuffer.write('-metadata:s:v title="Album cover" '); + cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); + } + + cmdBuffer.write('-c:a copy '); + + if (metadata != null) { + // Convert FLAC/Vorbis tags to ID3v2 tags for MP3 + final id3Metadata = _convertToId3Tags(metadata); + id3Metadata.forEach((key, value) { + final sanitizedValue = value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata $key="$sanitizedValue" '); + }); + } + + cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y'); + + final command = cmdBuffer.toString(); + _log.d('Executing FFmpeg MP3 embed command: $command'); + + final result = await _execute(command); + + if (result.success) { + try { + await File(mp3Path).delete(); + await File(tempOutput).rename(mp3Path); + _log.d('MP3 metadata embedded successfully'); + return mp3Path; + } catch (e) { + _log.e('Failed to replace MP3 file after metadata embed: $e'); + return null; + } + } + + try { + final tempFile = File(tempOutput); + if (await tempFile.exists()) { + await tempFile.delete(); + } + } catch (_) {} + + _log.e('MP3 Metadata/Cover embed failed: ${result.output}'); + return null; + } + + /// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags + static Map _convertToId3Tags(Map vorbisMetadata) { + final id3Map = {}; + + for (final entry in vorbisMetadata.entries) { + final key = entry.key.toUpperCase(); + final value = entry.value; + + // Map Vorbis comments to ID3v2 frame names + switch (key) { + case 'TITLE': + id3Map['title'] = value; + break; + case 'ARTIST': + id3Map['artist'] = value; + break; + case 'ALBUM': + id3Map['album'] = value; + break; + case 'ALBUMARTIST': + id3Map['album_artist'] = value; + break; + case 'TRACKNUMBER': + case 'TRACK': + id3Map['track'] = value; + break; + case 'DISCNUMBER': + case 'DISC': + id3Map['disc'] = value; + break; + case 'DATE': + case 'YEAR': + id3Map['date'] = value; + break; + case 'ISRC': + id3Map['TSRC'] = value; // ID3v2 ISRC frame + break; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + id3Map['lyrics'] = value; + break; + default: + // Pass through other tags as-is + id3Map[key.toLowerCase()] = value; + } + } + + return id3Map; + } + /// Check if FFmpeg is available static Future isAvailable() async { try { diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index dc2fb835..da99f5d8 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -38,6 +38,7 @@ dependencies: # Material Expressive 3 / Dynamic Color dynamic_color: ^1.7.0 material_color_utilities: ^0.11.1 + palette_generator: ^0.3.3+4 # Permissions permission_handler: ^12.0.1 From 1546d7da22eed485b51495e4702a47b9e002d14a Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 18:57:27 +0700 Subject: [PATCH 37/48] feat: add external LRC lyrics file support and fix locale parsing - Add lyrics mode setting (embed/external/both) for saving lyrics - Implement SaveLRCFile() in Go backend for all providers (Tidal, Qobuz, Amazon) - Fix locale parsing in app.dart to handle country codes (e.g., pt_PT -> Locale('pt', 'PT')) - Change Portuguese label from Portugal to Brasil in language settings --- CHANGELOG.md | 14 +++ go_backend/amazon.go | 31 ++++- go_backend/exports.go | 2 + go_backend/lyrics.go | 28 +++++ go_backend/qobuz.go | 31 ++++- go_backend/tidal.go | 31 ++++- lib/app.dart | 7 +- lib/l10n/app_localizations.dart | 54 +++++++++ lib/l10n/app_localizations_de.dart | 29 +++++ lib/l10n/app_localizations_en.dart | 29 +++++ lib/l10n/app_localizations_es.dart | 29 +++++ lib/l10n/app_localizations_fr.dart | 29 +++++ lib/l10n/app_localizations_hi.dart | 29 +++++ lib/l10n/app_localizations_id.dart | 29 +++++ lib/l10n/app_localizations_ja.dart | 29 +++++ lib/l10n/app_localizations_ko.dart | 29 +++++ lib/l10n/app_localizations_nl.dart | 29 +++++ lib/l10n/app_localizations_pt.dart | 29 +++++ lib/l10n/app_localizations_ru.dart | 29 +++++ lib/l10n/app_localizations_zh.dart | 29 +++++ lib/l10n/arb/app_en.arb | 20 ++++ lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 2 + lib/providers/settings_provider.dart | 8 ++ .../settings/appearance_settings_page.dart | 2 +- .../settings/download_settings_page.dart | 112 +++++++++++++++++- lib/services/platform_bridge.dart | 8 ++ 28 files changed, 680 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e62052..bd8f606f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [3.1.3] - 2026-01-19 + +### Added + +- **External LRC Lyrics File Support**: Option to save lyrics as separate .lrc files for compatibility with external music players + - New "Lyrics Mode" setting in Settings > Download > Lyrics section + - Three modes available: + - **Embed in file** (default): Lyrics stored inside FLAC metadata + - **External .lrc file**: Save lyrics as separate .lrc file next to audio file + - **Both**: Embed and save external .lrc file + - Perfect for players like Samsung Music that prefer external .lrc files + - LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile) + - Works with all download services (Tidal, Qobuz, Amazon) + ## [3.1.2] - 2026-01-19 ### Added diff --git a/go_backend/amazon.go b/go_backend/amazon.go index f5e6dc69..cb130bd4 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -580,13 +580,32 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Embed lyrics from parallel fetch + // Handle lyrics based on LyricsMode setting + // Mode: "embed" (default), "external" (.lrc file), "both" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) - if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - fmt.Println("[Amazon] Lyrics embedded successfully") + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" // default + } + + // Save external .lrc file if mode is "external" or "both" + if lyricsMode == "external" || lyricsMode == "both" { + GoLog("[Amazon] Saving external LRC file...\n") + if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { + GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr) + } else { + GoLog("[Amazon] LRC file saved: %s\n", lrcPath) + } + } + + // Embed lyrics if mode is "embed" or "both" + if lyricsMode == "embed" || lyricsMode == "both" { + GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { + GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) + } else { + fmt.Println("[Amazon] Lyrics embedded successfully") + } } } else if req.EmbedLyrics { fmt.Println("[Amazon] No lyrics available from parallel fetch") diff --git a/go_backend/exports.go b/go_backend/exports.go index 6d65ef0c..1468660f 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -161,6 +161,8 @@ type DownloadRequest struct { TidalID string `json:"tidal_id,omitempty"` QobuzID string `json:"qobuz_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"` + // Lyrics mode: "embed" (default), "external" (.lrc file), "both" + LyricsMode string `json:"lyrics_mode,omitempty"` } // DownloadResponse represents the result of a download diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 97254ff7..46d959d7 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -6,6 +6,8 @@ import ( "math" "net/http" "net/url" + "os" + "path/filepath" "regexp" "strconv" "strings" @@ -485,3 +487,29 @@ func simplifyTrackName(name string) string { return strings.TrimSpace(result) } + +// SaveLRCFile saves lyrics as a .lrc file next to the audio file +// audioFilePath: path to the audio file (e.g., /path/to/song.flac) +// lrcContent: the LRC format lyrics content +// Returns the path to the saved .lrc file, or error +func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { + if lrcContent == "" { + return "", fmt.Errorf("empty LRC content") + } + + // Get the directory and base name without extension + dir := filepath.Dir(audioFilePath) + ext := filepath.Ext(audioFilePath) + baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext) + + // Create the .lrc file path + lrcFilePath := filepath.Join(dir, baseName+".lrc") + + // Write the LRC content to the file + if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil { + return "", fmt.Errorf("failed to write LRC file: %w", err) + } + + GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath) + return lrcFilePath, nil +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 5165c127..0adb4a9d 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1135,13 +1135,32 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Embed lyrics from parallel fetch + // Handle lyrics based on LyricsMode setting + // Mode: "embed" (default), "external" (.lrc file), "both" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) - if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - fmt.Println("[Qobuz] Lyrics embedded successfully") + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" // default + } + + // Save external .lrc file if mode is "external" or "both" + if lyricsMode == "external" || lyricsMode == "both" { + GoLog("[Qobuz] Saving external LRC file...\n") + if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { + GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr) + } else { + GoLog("[Qobuz] LRC file saved: %s\n", lrcPath) + } + } + + // Embed lyrics if mode is "embed" or "both" + if lyricsMode == "embed" || lyricsMode == "both" { + GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { + GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) + } else { + fmt.Println("[Qobuz] Lyrics embedded successfully") + } } } else if req.EmbedLyrics { fmt.Println("[Qobuz] No lyrics available from parallel fetch") diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 45a92ca2..3b89015f 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1733,13 +1733,32 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Embed lyrics from parallel fetch + // Handle lyrics based on LyricsMode setting + // Mode: "embed" (default), "external" (.lrc file), "both" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) - if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - fmt.Println("[Tidal] Lyrics embedded successfully") + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" // default + } + + // Save external .lrc file if mode is "external" or "both" + if lyricsMode == "external" || lyricsMode == "both" { + GoLog("[Tidal] Saving external LRC file...\n") + if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { + GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) + } else { + GoLog("[Tidal] LRC file saved: %s\n", lrcPath) + } + } + + // Embed lyrics if mode is "embed" or "both" + if lyricsMode == "embed" || lyricsMode == "both" { + GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { + GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) + } else { + fmt.Println("[Tidal] Lyrics embedded successfully") + } } } else if req.EmbedLyrics { fmt.Println("[Tidal] No lyrics available from parallel fetch") diff --git a/lib/app.dart b/lib/app.dart index df0f2158..9d6c7b8b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -36,7 +36,12 @@ class SpotiFLACApp extends ConsumerWidget { Locale? locale; if (localeString != 'system') { - locale = Locale(localeString); + if (localeString.contains('_')) { + final parts = localeString.split('_'); + locale = Locale(parts[0], parts[1]); + } else { + locale = Locale(localeString); + } } return DynamicColorWrapper( diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 05bcad9f..da89b490 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2612,6 +2612,60 @@ abstract class AppLocalizations { /// **'File Settings'** String get sectionFileSettings; + /// Settings section header + /// + /// In en, this message translates to: + /// **'Lyrics'** + String get sectionLyrics; + + /// Setting - how to save lyrics + /// + /// In en, this message translates to: + /// **'Lyrics Mode'** + String get lyricsMode; + + /// Lyrics mode picker description + /// + /// In en, this message translates to: + /// **'Choose how lyrics are saved with your downloads'** + String get lyricsModeDescription; + + /// Lyrics mode option - embed in audio file + /// + /// In en, this message translates to: + /// **'Embed in file'** + String get lyricsModeEmbed; + + /// Subtitle for embed option + /// + /// In en, this message translates to: + /// **'Lyrics stored inside FLAC metadata'** + String get lyricsModeEmbedSubtitle; + + /// Lyrics mode option - separate LRC file + /// + /// In en, this message translates to: + /// **'External .lrc file'** + String get lyricsModeExternal; + + /// Subtitle for external option + /// + /// In en, this message translates to: + /// **'Separate .lrc file for players like Samsung Music'** + String get lyricsModeExternalSubtitle; + + /// Lyrics mode option - embed and external + /// + /// In en, this message translates to: + /// **'Both'** + String get lyricsModeBoth; + + /// Subtitle for both option + /// + /// In en, this message translates to: + /// **'Embed and save .lrc file'** + String get lyricsModeBothSubtitle; + /// Settings section header /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a02bc6c0..f9c1b25b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1443,6 +1443,35 @@ class AppLocalizationsDe extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index eadd58ce..93573f3e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1430,6 +1430,35 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 2acbccf3..5a44e2d9 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1430,6 +1430,35 @@ class AppLocalizationsEs extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 77d394b8..d94be538 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1430,6 +1430,35 @@ class AppLocalizationsFr extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 640394a4..83569552 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1430,6 +1430,35 @@ class AppLocalizationsHi extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a15135e6..46ecfbf9 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1440,6 +1440,35 @@ class AppLocalizationsId extends AppLocalizations { @override String get sectionFileSettings => 'Pengaturan File'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Warna'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index e88e31e3..3f301bd2 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1430,6 +1430,35 @@ class AppLocalizationsJa extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 20ab8701..aa747ec2 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1430,6 +1430,35 @@ class AppLocalizationsKo extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index e91357a9..6d4d14da 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1430,6 +1430,35 @@ class AppLocalizationsNl extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 25a17d29..a700c861 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1430,6 +1430,35 @@ class AppLocalizationsPt extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7aa28243..1158112b 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1458,6 +1458,35 @@ class AppLocalizationsRu extends AppLocalizations { @override String get sectionFileSettings => 'Настройки файла'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Цвет'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ac47c5fa..68f13857 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1430,6 +1430,35 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sectionFileSettings => 'File Settings'; + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + @override String get sectionColor => 'Color'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d54ab75d..8603a0b1 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1051,6 +1051,26 @@ "@sectionAudioQuality": {"description": "Settings section header"}, "sectionFileSettings": "File Settings", "@sectionFileSettings": {"description": "Settings section header"}, + "sectionLyrics": "Lyrics", + "@sectionLyrics": {"description": "Settings section header"}, + + "lyricsMode": "Lyrics Mode", + "@lyricsMode": {"description": "Setting - how to save lyrics"}, + "lyricsModeDescription": "Choose how lyrics are saved with your downloads", + "@lyricsModeDescription": {"description": "Lyrics mode picker description"}, + "lyricsModeEmbed": "Embed in file", + "@lyricsModeEmbed": {"description": "Lyrics mode option - embed in audio file"}, + "lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata", + "@lyricsModeEmbedSubtitle": {"description": "Subtitle for embed option"}, + "lyricsModeExternal": "External .lrc file", + "@lyricsModeExternal": {"description": "Lyrics mode option - separate LRC file"}, + "lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music", + "@lyricsModeExternalSubtitle": {"description": "Subtitle for external option"}, + "lyricsModeBoth": "Both", + "@lyricsModeBoth": {"description": "Lyrics mode option - embed and external"}, + "lyricsModeBothSubtitle": "Embed and save .lrc file", + "@lyricsModeBothSubtitle": {"description": "Subtitle for both option"}, + "sectionColor": "Color", "@sectionColor": {"description": "Settings section header"}, "sectionTheme": "Theme", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 0ff45cdb..00e308d0 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -32,6 +32,7 @@ class AppSettings { final bool showExtensionStore; // Show Extension Store tab in navigation final String locale; // App language: 'system', 'en', 'id', etc. final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion) + final String lyricsMode; // embed, external, both - how to save lyrics const AppSettings({ this.defaultService = 'tidal', @@ -62,6 +63,7 @@ class AppSettings { this.showExtensionStore = true, // Default: show store this.locale = 'system', // Default: follow system language this.enableMp3Option = false, // Default: disabled + this.lyricsMode = 'embed', // Default: embed lyrics into file }); AppSettings copyWith({ @@ -94,6 +96,7 @@ class AppSettings { bool? showExtensionStore, String? locale, bool? enableMp3Option, + String? lyricsMode, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -124,6 +127,7 @@ class AppSettings { showExtensionStore: showExtensionStore ?? this.showExtensionStore, locale: locale ?? this.locale, enableMp3Option: enableMp3Option ?? this.enableMp3Option, + lyricsMode: lyricsMode ?? this.lyricsMode, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 96962fa7..5225c989 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -37,6 +37,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( showExtensionStore: json['showExtensionStore'] as bool? ?? true, locale: json['locale'] as String? ?? 'system', enableMp3Option: json['enableMp3Option'] as bool? ?? false, + lyricsMode: json['lyricsMode'] as String? ?? 'embed', ); Map _$AppSettingsToJson(AppSettings instance) => @@ -69,4 +70,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'showExtensionStore': instance.showExtensionStore, 'locale': instance.locale, 'enableMp3Option': instance.enableMp3Option, + 'lyricsMode': instance.lyricsMode, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index c78ab9c0..03f35234 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1628,6 +1628,7 @@ class DownloadQueueNotifier extends Notifier { source: trackToDownload.source, // Pass extension ID that provided this track genre: genre, label: label, + lyricsMode: settings.lyricsMode, ); } else if (state.autoFallback) { _log.d('Using auto-fallback mode'); @@ -1655,6 +1656,7 @@ class DownloadQueueNotifier extends Notifier { trackToDownload.duration, // Duration in ms for verification genre: genre, label: label, + lyricsMode: settings.lyricsMode, ); } else { result = await PlatformBridge.downloadTrack( diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index bef594e9..2de3d839 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -92,6 +92,14 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setLyricsMode(String mode) { + // Valid modes: embed, external, both + if (mode == 'embed' || mode == 'external' || mode == 'both') { + state = state.copyWith(lyricsMode: mode); + _saveSettings(); + } + } + void setMaxQualityCover(bool enabled) { state = state.copyWith(maxQualityCover: enabled); _saveSettings(); diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index e8c3b023..4e039a85 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -707,7 +707,7 @@ static const _allLanguages = [ ('ko', '한국어', Icons.language), ('nl', 'Nederlands', Icons.language), ('pt', 'Português', Icons.language), - ('pt_PT', 'Português (Portugal)', Icons.language), + ('pt_PT', 'Português (Brasil)', Icons.language), ('ru', 'Русский', Icons.language), ('zh', '简体中文', Icons.language), ('zh_CN', '简体中文 (中国)', Icons.language), diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index b0a73f98..14b782a2 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -169,14 +169,35 @@ class DownloadSettingsPage extends ConsumerWidget { ], ), ), - ], ], - ), + ], ), + ), - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionFileSettings), + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionLyrics), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.lyrics_outlined, + title: context.l10n.lyricsMode, + subtitle: _getLyricsModeLabel(context, settings.lyricsMode), + onTap: () => _showLyricsModePicker( + context, + ref, + settings.lyricsMode, + ), + showDivider: false, + ), + ], ), + ), + + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionFileSettings), + ), SliverToBoxAdapter( child: SettingsGroup( children: [ @@ -606,6 +627,89 @@ class DownloadSettingsPage extends ConsumerWidget { } } + String _getLyricsModeLabel(BuildContext context, String mode) { + switch (mode) { + case 'external': + return context.l10n.lyricsModeExternal; + case 'both': + return context.l10n.lyricsModeBoth; + default: + return context.l10n.lyricsModeEmbed; + } + } + + void _showLyricsModePicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.lyricsMode, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.lyricsModeDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ListTile( + leading: const Icon(Icons.audiotrack), + title: Text(context.l10n.lyricsModeEmbed), + subtitle: Text(context.l10n.lyricsModeEmbedSubtitle), + trailing: current == 'embed' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLyricsMode('embed'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.insert_drive_file_outlined), + title: Text(context.l10n.lyricsModeExternal), + subtitle: Text(context.l10n.lyricsModeExternalSubtitle), + trailing: current == 'external' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLyricsMode('external'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.library_music_outlined), + title: Text(context.l10n.lyricsModeBoth), + subtitle: Text(context.l10n.lyricsModeBothSubtitle), + trailing: current == 'both' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setLyricsMode('both'); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + void _showFolderOrganizationPicker( BuildContext context, WidgetRef ref, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 91632c1c..8658a6d7 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -133,6 +133,8 @@ class PlatformBridge { String? genre, String? label, String? copyright, + // Lyrics mode: "embed" (default), "external" (.lrc file), "both" + String lyricsMode = 'embed', }) async { _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); final request = jsonEncode({ @@ -159,6 +161,8 @@ class PlatformBridge { 'genre': genre ?? '', 'label': label ?? '', 'copyright': copyright ?? '', + // Lyrics mode + 'lyrics_mode': lyricsMode, }); final result = await _channel.invokeMethod('downloadWithFallback', request); @@ -658,6 +662,8 @@ class PlatformBridge { String? source, // Extension ID that provided this track (prioritize this extension) String? genre, String? label, + // Lyrics mode: "embed" (default), "external" (.lrc file), "both" + String lyricsMode = 'embed', }) async { _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); final request = jsonEncode({ @@ -682,6 +688,8 @@ class PlatformBridge { 'source': source ?? '', // Extension ID that provided this track 'genre': genre ?? '', 'label': label ?? '', + // Lyrics mode + 'lyrics_mode': lyricsMode, }); final result = await _channel.invokeMethod('downloadWithExtensions', request); From 9c35515d6f7043404a408cace4c67f16b644c53b Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 18:58:25 +0700 Subject: [PATCH 38/48] docs: add code of conduct and contributing guidelines --- CODE_OF_CONDUCT.md | 133 ++++++++++++++++++++++ CONTRIBUTING.md | 268 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d284332d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**[zarzet](https://github.com/zarzet)**. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..f1750732 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,268 @@ +# Contributing to SpotiFLAC + +First off, thank you for considering contributing to SpotiFLAC! 🎉 + +This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [How Can I Contribute?](#how-can-i-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Features](#suggesting-features) + - [Code Contributions](#code-contributions) + - [Translations](#translations) +- [Development Setup](#development-setup) +- [Project Structure](#project-structure) +- [Coding Guidelines](#coding-guidelines) +- [Commit Guidelines](#commit-guidelines) +- [Pull Request Process](#pull-request-process) + +## Code of Conduct + +This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates. + +When creating a bug report, please use the bug report template and include: + +- **Clear and descriptive title** +- **Steps to reproduce** the issue +- **Expected behavior** vs **actual behavior** +- **Screenshots or screen recordings** if applicable +- **Device information** (model, OS version) +- **App version** +- **Logs** from Settings > About > View Logs + +### Suggesting Features + +Feature requests are welcome! Please use the feature request template and: + +- **Check existing issues** to avoid duplicates +- **Describe the feature** clearly +- **Explain the use case** - why would this be useful? +- **Consider the scope** - is this a small enhancement or a major feature? + +### Code Contributions + +1. **Fork the repository** and create your branch from `dev` +2. **Make your changes** following our coding guidelines +3. **Test your changes** thoroughly +4. **Submit a pull request** to the `dev` branch + +### Translations + +We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute: + +1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile) +2. Select your language or request a new one +3. Start translating! + +Translation files are located in `lib/l10n/arb/`. + +## Development Setup + +### Prerequisites + +- **Flutter SDK** 3.10.0 or higher +- **Dart SDK** 3.10.0 or higher +- **Android Studio** or **VS Code** with Flutter extensions +- **Git** + +### Getting Started + +1. **Clone your fork** + ```bash + git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git + cd SpotiFLAC-Mobile + ``` + +2. **Add upstream remote** + ```bash + git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git + ``` + +3. **Install dependencies** + ```bash + flutter pub get + ``` + +4. **Generate code** (for Riverpod, JSON serialization, etc.) + ```bash + dart run build_runner build --delete-conflicting-outputs + ``` + +5. **Run the app** + ```bash + flutter run + ``` + +### Building + +```bash +# Debug build +flutter build apk --debug + +# Release build +flutter build apk --release +``` + +## Project Structure + +``` +lib/ +├── l10n/ # Localization files +│ └── arb/ # ARB translation files +├── models/ # Data models +├── providers/ # Riverpod providers +├── screens/ # UI screens +│ └── settings/ # Settings sub-screens +├── services/ # Business logic services +├── theme/ # App theming +├── utils/ # Utility functions +├── widgets/ # Reusable widgets +├── app.dart # App configuration +└── main.dart # Entry point +``` + +## Coding Guidelines + +### General + +- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines +- Use meaningful variable and function names +- Keep functions small and focused +- Add comments for complex logic + +### Formatting + +- Use `dart format` before committing +- Maximum line length: 80 characters +- Use trailing commas for better formatting + +```bash +dart format . +``` + +### Linting + +Ensure your code passes all lints: + +```bash +flutter analyze +``` + +### State Management + +We use **Riverpod** for state management. Follow these patterns: + +```dart +// Use code generation with riverpod_annotation +@riverpod +class MyNotifier extends _$MyNotifier { + @override + MyState build() => MyState(); + + // Methods to update state +} +``` + +### Localization + +All user-facing strings should be localized: + +```dart +// Good +Text(AppLocalizations.of(context)!.downloadComplete) + +// Bad +Text('Download Complete') +``` + +To add new strings: +1. Add the key to `lib/l10n/arb/app_en.arb` +2. Run `flutter gen-l10n` + +## Commit Guidelines + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +### Types + +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `style`: Code style changes (formatting, etc.) +- `refactor`: Code refactoring +- `perf`: Performance improvements +- `test`: Adding or updating tests +- `chore`: Maintenance tasks + +### Examples + +``` +feat(download): add batch download support +fix(ui): resolve overflow on small screens +docs: update contributing guidelines +chore(deps): update flutter_riverpod to 3.1.0 +``` + +## Pull Request Process + +1. **Update your fork** + ```bash + git fetch upstream + git rebase upstream/dev + ``` + +2. **Create a feature branch** + ```bash + git checkout -b feat/my-new-feature + ``` + +3. **Make your changes** and commit following our guidelines + +4. **Push to your fork** + ```bash + git push origin feat/my-new-feature + ``` + +5. **Create a Pull Request** + - Target the `dev` branch + - Fill in the PR template + - Link related issues + +6. **Address review feedback** + - Make requested changes + - Push additional commits + - Request re-review when ready + +### PR Requirements + +- [ ] Code follows project conventions +- [ ] All tests pass +- [ ] No new linting errors +- [ ] Documentation updated (if needed) +- [ ] Commit messages follow guidelines +- [ ] PR description is clear and complete + +## Questions? + +If you have questions, feel free to: + +- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions) +- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) + +Thank you for contributing! 💚 From 0119db094dce35ad037b233fb522c96ff0ba6895 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 21:11:57 +0700 Subject: [PATCH 39/48] feat: add extended metadata (genre, label, copyright) support - Add genre, label, copyright fields to ExtTrackMetadata and DownloadResponse - Add utils.randomUserAgent() for extensions to get random User-Agent strings - Fix VM race condition panic by adding mutex locks to all provider methods - Fix Tidal release date fallback when req.ReleaseDate is empty - Display genre, label, copyright in track metadata screen - Store extended metadata in download history for persistence - Add trackGenre, trackLabel, trackCopyright localization strings --- .github/FUNDING.yml | 1 + CHANGELOG.md | 74 ++++++++++++++++++ go_backend/exports.go | 4 + go_backend/extension_manager.go | 1 + go_backend/extension_providers.go | 82 ++++++++++++++++++++ go_backend/extension_runtime.go | 1 + go_backend/extension_runtime_utils.go | 5 ++ go_backend/httputil.go | 15 ++-- go_backend/tidal.go | 9 ++- lib/l10n/app_localizations.dart | 24 ++++++ lib/l10n/app_localizations_de.dart | 14 ++++ lib/l10n/app_localizations_en.dart | 14 ++++ lib/l10n/app_localizations_es.dart | 14 ++++ lib/l10n/app_localizations_fr.dart | 14 ++++ lib/l10n/app_localizations_hi.dart | 14 ++++ lib/l10n/app_localizations_id.dart | 14 ++++ lib/l10n/app_localizations_ja.dart | 14 ++++ lib/l10n/app_localizations_ko.dart | 14 ++++ lib/l10n/app_localizations_nl.dart | 14 ++++ lib/l10n/app_localizations_pt.dart | 14 ++++ lib/l10n/app_localizations_ru.dart | 14 ++++ lib/l10n/app_localizations_zh.dart | 14 ++++ lib/l10n/arb/app_en.arb | 13 ++++ lib/providers/download_queue_provider.dart | 57 +++++++++++++- lib/screens/home_tab.dart | 87 ++++++++++++++-------- lib/screens/track_metadata_screen.dart | 9 +++ 26 files changed, 509 insertions(+), 41 deletions(-) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..326c8513 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: zarzet diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8f606f..af3d6a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,80 @@ - LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile) - Works with all download services (Tidal, Qobuz, Amazon) +- **CSV Import Quality Selection**: Choose audio quality when importing CSV playlists + - Quality picker now appears before adding CSV tracks to download queue + - Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3 + - Respects "Ask quality before download" setting - uses default quality if disabled + +- **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer + - New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre` + - Metadata fetched during `enrichTrack()` via Deezer album API + - Embedded as FLAC Vorbis comments: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT` + - Works for both extension downloads and built-in provider downloads (Tidal, Qobuz, Amazon) + +- **Track Metadata Screen Extended Info**: Genre, label, and copyright now displayed in track metadata screen + - Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model + - Metadata is stored in download history and persists across app restarts + - New localization strings: `trackGenre`, `trackLabel`, `trackCopyright` + +- **`utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings + - Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0` + - Useful for extensions that need to rotate User-Agents to avoid detection + +### Fixed + +- **Portuguese Language Bug**: Fixed locale parsing for languages with country codes (e.g., pt_PT, es_ES) + - App now correctly loads Portuguese and Spanish translations + - Updated Portuguese label to "Português (Brasil)" + +- **VM Race Condition Panic**: Fixed `panic during execution: runtime error: index out of range [-2]` crash when switching search providers + - Root cause: Goja VM was being accessed concurrently by multiple goroutines without synchronization + - Added `VMMu sync.Mutex` to `LoadedExtension` struct + - Added mutex lock/unlock to ALL `ExtensionProviderWrapper` methods: + - `SearchTracks`, `GetTrack`, `GetAlbum`, `GetArtist` + - `EnrichTrack`, `CheckAvailability`, `GetDownloadURL`, `Download` + - `CustomSearch`, `HandleURL`, `MatchTrack`, `PostProcess` + - Prevents race conditions when rapidly switching between extension search providers + +- **Tidal Release Date Fallback**: Fixed missing release date in FLAC metadata when downloading from Tidal + - Now uses Tidal API's release date when `req.ReleaseDate` is empty + - Ensures release date is always embedded in downloaded files + +- **Extended Metadata for M4A→FLAC Conversion**: Fixed genre, label, and copyright not being embedded when converting Amazon M4A to FLAC + - Flutter now extracts extended metadata from Go backend response + - Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()` + - Tags correctly embedded during FFmpeg conversion + +### Extensions + +- **spotify-web Extension**: Updated to v1.7.0 + - Added `getMetadataFromDeezer()` function to fetch extended metadata: + - ISRC from track + - Label from album + - Copyright (generated as "YEAR LABEL") + - Genre from album genres + - Release date + - `enrichTrack()` now returns all extended metadata to Go backend + - Replaced all hardcoded User-Agent strings with `utils.randomUserAgent()` + +### Technical + +- **Go Backend Changes**: + - `go_backend/extension_providers.go`: Added `Label`, `Copyright`, `Genre` fields to `ExtTrackMetadata`; added mutex locks to all provider methods + - `go_backend/extension_manager.go`: Added `VMMu sync.Mutex` to `LoadedExtension` struct + - `go_backend/extension_runtime.go`: Added `utils.randomUserAgent` function + - `go_backend/extension_runtime_utils.go`: Added `randomUserAgent()` implementation + - `go_backend/httputil.go`: Updated `getRandomUserAgent()` to use modern Chrome versions + - `go_backend/tidal.go`: Added release date fallback logic + - `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse` + +- **Flutter Changes**: + - `lib/providers/download_queue_provider.dart`: Updated `_embedMetadataAndCover()` to accept and embed genre, label, copyright; added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` + - `lib/screens/track_metadata_screen.dart`: Display genre, label, copyright in metadata grid + - `lib/l10n/arb/app_en.arb`: Added `trackGenre`, `trackLabel`, `trackCopyright` localization strings + +--- + ## [3.1.2] - 2026-01-19 ### Added diff --git a/go_backend/exports.go b/go_backend/exports.go index 1468660f..d5268f56 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -186,6 +186,10 @@ type DownloadResponse struct { DiscNumber int `json:"disc_number,omitempty"` ISRC string `json:"isrc,omitempty"` CoverURL string `json:"cover_url,omitempty"` + // Extended metadata for FLAC tagging (passed to Flutter for M4A->FLAC conversion) + Genre string `json:"genre,omitempty"` // Music genre(s) + Label string `json:"label,omitempty"` // Record label + Copyright string `json:"copyright,omitempty"` // Copyright info // If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata) SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index a601c1a4..9ab396aa 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -51,6 +51,7 @@ type LoadedExtension struct { ID string `json:"id"` Manifest *ExtensionManifest `json:"manifest"` VM *goja.Runtime `json:"-"` + VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access Enabled bool `json:"enabled"` Error string `json:"error,omitempty"` DataDir string `json:"data_dir"` diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 456bee20..83c63629 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -38,6 +38,10 @@ type ExtTrackMetadata struct { DeezerID string `json:"deezer_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"` ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping + // Extended metadata from enrichment (can come from Deezer, Spotify, etc.) + Label string `json:"label,omitempty"` // Record label + Copyright string `json:"copyright,omitempty"` // Copyright information + Genre string `json:"genre,omitempty"` // Music genre(s) } // ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields @@ -144,6 +148,10 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + // Call extension's searchTracks function script := fmt.Sprintf(` (function() { @@ -206,6 +214,10 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') { @@ -252,6 +264,10 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { @@ -301,6 +317,10 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') { @@ -349,6 +369,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra return track, nil // Extension disabled, return as-is } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + // Convert track to JSON for passing to JS trackJSON, err := json.Marshal(track) if err != nil { @@ -415,6 +439,10 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { @@ -460,6 +488,10 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') { @@ -508,6 +540,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + // Set up progress callback in VM p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { if len(call.Arguments) > 0 { @@ -758,6 +794,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if enrichedTrack.Artists != "" { req.ArtistName = enrichedTrack.Artists } + // Copy extended metadata from enrichment (label, copyright, genre, release_date) + if enrichedTrack.Label != "" && req.Label == "" { + GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label) + req.Label = enrichedTrack.Label + } + if enrichedTrack.Copyright != "" && req.Copyright == "" { + GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright) + req.Copyright = enrichedTrack.Copyright + } + if enrichedTrack.Genre != "" && req.Genre == "" { + GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre) + req.Genre = enrichedTrack.Genre + } + if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" { + GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate) + req.ReleaseDate = enrichedTrack.ReleaseDate + } } } } @@ -891,6 +944,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro result, err := tryBuiltInProvider(providerID, req) if err == nil && result.Success { result.Service = providerID + // Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion) + if req.Label != "" { + result.Label = req.Label + } + if req.Copyright != "" { + result.Copyright = req.Copyright + } + if req.Genre != "" { + result.Genre = req.Genre + } + if req.ReleaseDate != "" && result.ReleaseDate == "" { + result.ReleaseDate = req.ReleaseDate + } return result, nil } if err != nil { @@ -1138,6 +1204,10 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + // Convert options to JSON optionsJSON, _ := json.Marshal(options) @@ -1209,6 +1279,10 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') { @@ -1290,6 +1364,10 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + sourceJSON, _ := json.Marshal(sourceTrack) candidatesJSON, _ := json.Marshal(candidates) @@ -1353,6 +1431,10 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + metadataJSON, _ := json.Marshal(metadata) script := fmt.Sprintf(` diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index a41b4ef6..33de3653 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -299,6 +299,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("decrypt", r.cryptoDecrypt) utilsObj.Set("generateKey", r.cryptoGenerateKey) + utilsObj.Set("randomUserAgent", r.randomUserAgent) vm.Set("utils", utilsObj) // Log object (already set in extension_manager.go, but we can enhance it) diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index cd3819c1..abed98b4 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -268,6 +268,11 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value }) } +// randomUserAgent returns a random Chrome User-Agent string +func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value { + return r.vm.ToValue(getRandomUserAgent()) +} + // ==================== Logging Functions ==================== func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 0700cfde..3a9e2b80 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -18,17 +18,16 @@ import ( // HTTP utility functions for consistent request handling across all downloaders // getRandomUserAgent generates a random Windows Chrome User-Agent string -// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility +// Uses modern Chrome format with build and patch numbers +// Windows 11 still reports as "Windows NT 10.0" for compatibility func getRandomUserAgent() string { - winMajor := rand.Intn(2) + 10 - - chromeVersion := rand.Intn(25) + 100 - chromeBuild := rand.Intn(1500) + 3000 - chromePatch := rand.Intn(65) + 60 + // Chrome version 120-145 (modern range) + chromeVersion := rand.Intn(26) + 120 + chromeBuild := rand.Intn(1500) + 6000 + chromePatch := rand.Intn(200) + 100 return fmt.Sprintf( - "Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36", - winMajor, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36", chromeVersion, chromeBuild, chromePatch, diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 3b89015f..aeeef441 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1706,12 +1706,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } // Embed metadata using parallel-fetched cover data + // Use release date from Tidal API if not provided in request + releaseDate := req.ReleaseDate + if releaseDate == "" && track.Album.ReleaseDate != "" { + releaseDate = track.Album.ReleaseDate + GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate) + } + metadata := Metadata{ Title: req.TrackName, Artist: req.ArtistName, Album: req.AlbumName, AlbumArtist: req.AlbumArtist, - Date: req.ReleaseDate, + Date: releaseDate, TrackNumber: track.TrackNumber, // Use actual track number from Tidal TotalTracks: req.TotalTracks, DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index da89b490..4cdaa83d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1688,6 +1688,12 @@ abstract class AppLocalizations { /// **'Found {count} tracks in CSV. Add them to download queue?'** String dialogImportPlaylistMessage(int count); + /// Label shown in quality picker for CSV import + /// + /// In en, this message translates to: + /// **'{count} tracks from CSV'** + String csvImportTracks(int count); + /// Snackbar - track added to download queue /// /// In en, this message translates to: @@ -2870,6 +2876,24 @@ abstract class AppLocalizations { /// **'Release date'** String get trackReleaseDate; + /// Metadata label - music genre + /// + /// In en, this message translates to: + /// **'Genre'** + String get trackGenre; + + /// Metadata label - record label + /// + /// In en, this message translates to: + /// **'Label'** + String get trackLabel; + + /// Metadata label - copyright information + /// + /// In en, this message translates to: + /// **'Copyright'** + String get trackCopyright; + /// Metadata label - download date /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f9c1b25b..2dd62b0d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -910,6 +910,11 @@ class AppLocalizationsDe extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1584,6 +1589,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 93573f3e..de382bda 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -897,6 +897,11 @@ class AppLocalizationsEn extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 5a44e2d9..0ff7baad 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -897,6 +897,11 @@ class AppLocalizationsEs extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index d94be538..92bf6148 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -897,6 +897,11 @@ class AppLocalizationsFr extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 83569552..f95470da 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -897,6 +897,11 @@ class AppLocalizationsHi extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 46ecfbf9..258ebad7 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -903,6 +903,11 @@ class AppLocalizationsId extends AppLocalizations { return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Menambahkan \"$trackName\" ke antrian'; @@ -1581,6 +1586,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackReleaseDate => 'Tanggal rilis'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Diunduh'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 3f301bd2..9dd91796 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -897,6 +897,11 @@ class AppLocalizationsJa extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index aa747ec2..4b3487af 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -897,6 +897,11 @@ class AppLocalizationsKo extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 6d4d14da..67086594 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -897,6 +897,11 @@ class AppLocalizationsNl extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index a700c861..42c9e1c6 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -897,6 +897,11 @@ class AppLocalizationsPt extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 1158112b..8bd4c674 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -919,6 +919,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'Найдено $count треков в CSV. Добавить их в очередь загрузки?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return '\"$trackName\" добавлен в очередь'; @@ -1603,6 +1608,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackReleaseDate => 'Дата выхода'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Скачано'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 68f13857..30835ee2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -897,6 +897,11 @@ class AppLocalizationsZh extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 8603a0b1..fce509d7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -619,6 +619,13 @@ "dialogImportPlaylistTitle": "Import Playlist", "@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"}, "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", + "csvImportTracks": "{count} tracks from CSV", + "@csvImportTracks": { + "description": "Label shown in quality picker for CSV import", + "placeholders": { + "count": {"type": "int"} + } + }, "@dialogImportPlaylistMessage": { "description": "Dialog message - import playlist confirmation", "placeholders": { @@ -1153,6 +1160,12 @@ "@trackAudioQuality": {"description": "Metadata label - audio quality"}, "trackReleaseDate": "Release date", "@trackReleaseDate": {"description": "Metadata label - release date"}, + "trackGenre": "Genre", + "@trackGenre": {"description": "Metadata label - music genre"}, + "trackLabel": "Label", + "@trackLabel": {"description": "Metadata label - record label"}, + "trackCopyright": "Copyright", + "@trackCopyright": {"description": "Metadata label - copyright information"}, "trackDownloaded": "Downloaded", "@trackDownloaded": {"description": "Metadata label - download date"}, "trackCopyLyrics": "Copy lyrics", diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 03f35234..f3ce7c8a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -45,6 +45,9 @@ class DownloadHistoryItem { final String? quality; final int? bitDepth; final int? sampleRate; + final String? genre; + final String? label; + final String? copyright; const DownloadHistoryItem({ required this.id, @@ -65,6 +68,9 @@ class DownloadHistoryItem { this.quality, this.bitDepth, this.sampleRate, + this.genre, + this.label, + this.copyright, }); Map toJson() => { @@ -86,6 +92,9 @@ class DownloadHistoryItem { 'quality': quality, 'bitDepth': bitDepth, 'sampleRate': sampleRate, + 'genre': genre, + 'label': label, + 'copyright': copyright, }; factory DownloadHistoryItem.fromJson(Map json) => @@ -108,6 +117,9 @@ class DownloadHistoryItem { quality: json['quality'] as String?, bitDepth: json['bitDepth'] as int?, sampleRate: json['sampleRate'] as int?, + genre: json['genre'] as String?, + label: json['label'] as String?, + copyright: json['copyright'] as String?, ); } @@ -1016,7 +1028,13 @@ class DownloadQueueNotifier extends Notifier { } /// Embed metadata and cover to a FLAC file after M4A conversion - Future _embedMetadataAndCover(String flacPath, Track track) async { + Future _embedMetadataAndCover( + String flacPath, + Track track, { + String? genre, + String? label, + String? copyright, + }) async { final settings = ref.read(settingsProvider); String? coverPath; @@ -1083,6 +1101,20 @@ class DownloadQueueNotifier extends Notifier { metadata['ISRC'] = track.isrc!; } + // Extended metadata from enrichment (genre, label, copyright) + if (genre != null && genre.isNotEmpty) { + metadata['GENRE'] = genre; + _log.d('Adding GENRE: $genre'); + } + if (label != null && label.isNotEmpty) { + metadata['ORGANIZATION'] = label; + _log.d('Adding ORGANIZATION (label): $label'); + } + if (copyright != null && copyright.isNotEmpty) { + metadata['COPYRIGHT'] = copyright; + _log.d('Adding COPYRIGHT: $copyright'); + } + _log.d('Metadata map content: $metadata'); try { @@ -1809,7 +1841,22 @@ class DownloadQueueNotifier extends Notifier { ); } - await _embedMetadataAndCover(flacPath, finalTrack); + // Get extended metadata from backend response + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + if (backendGenre != null || backendLabel != null || backendCopyright != null) { + _log.d('Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright'); + } + + await _embedMetadataAndCover( + flacPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); _log.d('Metadata and cover embedded successfully'); } catch (e) { _log.w('Warning: Failed to embed metadata/cover: $e'); @@ -1920,6 +1967,9 @@ class DownloadQueueNotifier extends Notifier { final backendBitDepth = result['actual_bit_depth'] as int?; final backendSampleRate = result['actual_sample_rate'] as int?; final backendISRC = result['isrc'] as String?; + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); @@ -1970,6 +2020,9 @@ class DownloadQueueNotifier extends Notifier { quality: actualQuality, bitDepth: historyBitDepth, sampleRate: historySampleRate, + genre: backendGenre, + label: backendLabel, + copyright: backendCopyright, ), ); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 90179966..df6926fc 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -355,37 +355,64 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // ignore: use_build_context_synchronously final l10n = context.l10n; - final confirmed = await showDialog( - context: this.context, - builder: (dialogCtx) => AlertDialog( - title: Text(l10n.dialogImportPlaylistTitle), - content: Text(l10n.dialogImportPlaylistMessage(tracks.length)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogCtx, false), - child: Text(l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogCtx, true), - child: Text(l10n.dialogImport), - ), - ], - ), - ); - - if (confirmed == true) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), - action: SnackBarAction( - label: l10n.snackbarViewQueue, - onPressed: () { - }, + // Show quality picker if enabled in settings + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + this.context, + trackName: l10n.csvImportTracks(tracks.length), + artistName: l10n.dialogImportPlaylistTitle, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue( + tracks, + service, + qualityOverride: quality, + ); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } + }, + ); + } else { + // Use default settings without quality picker + final confirmed = await showDialog( + context: this.context, + builder: (dialogCtx) => AlertDialog( + title: Text(l10n.dialogImportPlaylistTitle), + content: Text(l10n.dialogImportPlaylistMessage(tracks.length)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogCtx, false), + child: Text(l10n.dialogCancel), ), - ), - ); + FilledButton( + onPressed: () => Navigator.pop(dialogCtx, true), + child: Text(l10n.dialogImport), + ), + ], + ), + ); + + if (confirmed == true) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } } } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 603c1939..d360f196 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -119,6 +119,9 @@ class _TrackMetadataScreenState extends ConsumerState { int? get discNumber => item.discNumber; String? get releaseDate => item.releaseDate; String? get isrc => item.isrc; + String? get genre => item.genre; + String? get label => item.label; + String? get copyright => item.copyright; String get cleanFilePath { final path = item.filePath; @@ -519,6 +522,12 @@ class _TrackMetadataScreenState extends ConsumerState { _MetadataItem(context.l10n.trackAudioQuality, audioQualityStr), if (releaseDate != null && releaseDate!.isNotEmpty) _MetadataItem(context.l10n.trackReleaseDate, releaseDate!), + if (genre != null && genre!.isNotEmpty) + _MetadataItem(context.l10n.trackGenre, genre!), + if (label != null && label!.isNotEmpty) + _MetadataItem(context.l10n.trackLabel, label!), + if (copyright != null && copyright!.isNotEmpty) + _MetadataItem(context.l10n.trackCopyright, copyright!), if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!), ]; From 77e4457244992bc7044a62f19e9090c8e1185ca3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 22:55:53 +0700 Subject: [PATCH 40/48] feat: add persistent cover image cache - Add CoverCacheManager service for persistent image caching - Cache stored in app_flutter/cover_cache/ (not cleared by system) - Maximum 1000 images cached for up to 365 days - Update all 11 screens to use persistent cache manager - Add flutter_cache_manager and path dependencies - Update CHANGELOG.md with all changes for v3.1.3 --- CHANGELOG.md | 39 +++++ go_backend/deezer.go | 24 ++- lib/main.dart | 10 +- lib/providers/download_queue_provider.dart | 182 +++++++++++++++------ lib/providers/track_provider.dart | 3 + lib/screens/album_screen.dart | 14 +- lib/screens/artist_screen.dart | 16 +- lib/screens/downloaded_album_screen.dart | 55 ++++--- lib/screens/home_screen.dart | 7 +- lib/screens/home_tab.dart | 64 ++++++-- lib/screens/playlist_screen.dart | 14 +- lib/screens/queue_screen.dart | 4 +- lib/screens/queue_tab.dart | 157 +++++++++--------- lib/screens/search_screen.dart | 4 +- lib/screens/settings/about_page.dart | 4 +- lib/screens/track_metadata_screen.dart | 77 +++++---- lib/services/cover_cache_manager.dart | 114 +++++++++++++ lib/widgets/cached_cover_image.dart | 69 ++++++++ pubspec.lock | 4 +- pubspec.yaml | 4 +- pubspec_ios.yaml | 6 +- 21 files changed, 650 insertions(+), 221 deletions(-) create mode 100644 lib/services/cover_cache_manager.dart create mode 100644 lib/widgets/cached_cover_image.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index af3d6a6c..40296531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,18 @@ # Changelog +## [Unreleased] + ## [3.1.3] - 2026-01-19 ### Added +- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory + - Cover images no longer disappear when app is closed or device restarts + - Cache stored in `app_flutter/cover_cache/` directory (not cleared by system) + - Maximum 1000 images cached for up to 365 days + - Covers are cached when displayed in History, Home, Album, Artist, or any other screen + - New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management + - **External LRC Lyrics File Support**: Option to save lyrics as separate .lrc files for compatibility with external music players - New "Lyrics Mode" setting in Settings > Download > Lyrics section - Three modes available: @@ -70,6 +79,27 @@ - `enrichTrack()` now returns all extended metadata to Go backend - Replaced all hardcoded User-Agent strings with `utils.randomUserAgent()` +### Performance + +- **Faster App Startup**: Notification, Share Intent, and Cover Cache Manager initialization now run in parallel +- **Download Queue Polling**: Batched progress updates reduce rebuilds and list allocations during active downloads +- **Queue Item Updates**: Status/progress updates now skip no-op changes and update by index for fewer allocations +- **Directory Creation**: Download output folders are created once per path, reducing repeated I/O for albums/singles +- **Search Results Rendering**: Single-pass filtering avoids repeated `indexOf` calls for large result sets +- **Queue Lookups in UI**: O(1) lookup for queue status in Home/Album/Playlist/Artist track lists +- **History Filtering**: Album/single counts and grouping are computed once per build +- **Downloaded Album View**: Tracks are grouped by disc in one pass to reduce filtering overhead +- **Track Metadata Screen**: + - Palette extraction deferred until after transition; reduced sample size for smoother navigation + - File stat uses a single syscall and only triggers state updates on change + - Static regex/month table avoids repeated allocations + - Cover precached before opening metadata from history/queue/recents + +### Backend + +- **Deezer ISRC Fetching**: Uses ISRCs already present in payloads and caches them, cutting extra API calls +- **SearchAll Allocation**: Preallocated slices to reduce allocations during Deezer search + ### Technical - **Go Backend Changes**: @@ -82,10 +112,19 @@ - `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse` - **Flutter Changes**: + - `lib/services/cover_cache_manager.dart`: New persistent cache manager for cover images (365 days, 1000 images max) + - `lib/widgets/cached_cover_image.dart`: Wrapper widget for CachedNetworkImage with persistent cache + - `lib/main.dart`: Added `CoverCacheManager.initialize()` to app startup + - `lib/screens/*.dart`: All 11 screens updated to use persistent cache manager for CachedNetworkImage - `lib/providers/download_queue_provider.dart`: Updated `_embedMetadataAndCover()` to accept and embed genre, label, copyright; added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` - `lib/screens/track_metadata_screen.dart`: Display genre, label, copyright in metadata grid - `lib/l10n/arb/app_en.arb`: Added `trackGenre`, `trackLabel`, `trackCopyright` localization strings +### Dependencies + +- Added `flutter_cache_manager: ^3.4.1` (explicit dependency for persistent cache) +- Added `path: ^1.9.0` (for cache directory path handling) + --- ## [3.1.2] - 2026-01-19 diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 03ef7fdc..9b6ff111 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -201,8 +201,8 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, c.cacheMu.RUnlock() result := &SearchAllResult{ - Tracks: make([]TrackMetadata, 0), - Artists: make([]SearchArtistResult, 0), + Tracks: make([]TrackMetadata, 0, trackLimit), + Artists: make([]SearchArtistResult, 0, artistLimit), } // Search tracks - NO ISRC fetch for performance @@ -577,13 +577,24 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee // fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { - result := make(map[string]string) + result := make(map[string]string, len(tracks)) var resultMu sync.Mutex var tracksToFetch []deezerTrack + var directISRCs map[string]string c.cacheMu.RLock() for _, track := range tracks { trackIDStr := fmt.Sprintf("%d", track.ID) + if track.ISRC != "" { + result[trackIDStr] = track.ISRC + if _, ok := c.isrcCache[trackIDStr]; !ok { + if directISRCs == nil { + directISRCs = make(map[string]string) + } + directISRCs[trackIDStr] = track.ISRC + } + continue + } if isrc, ok := c.isrcCache[trackIDStr]; ok { result[trackIDStr] = isrc } else { @@ -591,6 +602,13 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr } } c.cacheMu.RUnlock() + if len(directISRCs) > 0 { + c.cacheMu.Lock() + for trackIDStr, isrc := range directISRCs { + c.isrcCache[trackIDStr] = isrc + } + c.cacheMu.Unlock() + } if len(tracksToFetch) == 0 { return result diff --git a/lib/main.dart b/lib/main.dart index 1a5439fd..ff3ec625 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,13 +7,15 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - - await NotificationService().initialize(); - - await ShareIntentService().initialize(); + await Future.wait([ + NotificationService().initialize(), + ShareIntentService().initialize(), + CoverCacheManager.initialize(), + ]); runApp( ProviderScope( diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index f3ce7c8a..e1e27bdc 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -372,6 +372,18 @@ class DownloadQueueState { items.where((i) => i.status == DownloadStatus.downloading).length; } +class _ProgressUpdate { + final DownloadStatus status; + final double progress; + final double? speedMBps; + + const _ProgressUpdate({ + required this.status, + required this.progress, + this.speedMBps, + }); +} + class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; int _downloadCount = 0; // Counter for connection cleanup @@ -383,6 +395,7 @@ class DownloadQueueNotifier extends Notifier { int _completedInSession = 0; // Track completed downloads in current session int _failedInSession = 0; // Track failed downloads in current session bool _isLoaded = false; + final Set _ensuredDirs = {}; @override DownloadQueueState build() { @@ -475,6 +488,15 @@ class DownloadQueueNotifier extends Notifier { try { final allProgress = await PlatformBridge.getAllDownloadProgress(); final items = allProgress['items'] as Map? ?? {}; + final currentItems = state.items; + final itemsById = {}; + final itemIndexById = {}; + for (int i = 0; i < currentItems.length; i++) { + final item = currentItems[i]; + itemsById[item.id] = item; + itemIndexById[item.id] = i; + } + final progressUpdates = {}; bool hasFinalizingItem = false; String? finalizingTrackName; @@ -482,9 +504,7 @@ class DownloadQueueNotifier extends Notifier { for (final entry in items.entries) { final itemId = entry.key; - final localItem = state.items - .where((i) => i.id == itemId) - .firstOrNull; + final localItem = itemsById[itemId]; if (localItem == null) { continue; } @@ -506,16 +526,13 @@ class DownloadQueueNotifier extends Notifier { final status = itemProgress['status'] as String? ?? 'downloading'; if (status == 'finalizing' && bytesTotal > 0) { - updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0); - - final currentItem = state.items - .where((i) => i.id == itemId) - .firstOrNull; - if (currentItem != null) { - hasFinalizingItem = true; - finalizingTrackName = currentItem.track.name; - finalizingArtistName = currentItem.track.artistName; - } + progressUpdates[itemId] = const _ProgressUpdate( + status: DownloadStatus.finalizing, + progress: 1.0, + ); + hasFinalizingItem = true; + finalizingTrackName = localItem.track.name; + finalizingArtistName = localItem.track.artistName; continue; } @@ -530,7 +547,11 @@ class DownloadQueueNotifier extends Notifier { percentage = progressFromBackend; } - updateProgress(itemId, percentage, speedMBps: speedMBps); + progressUpdates[itemId] = _ProgressUpdate( + status: DownloadStatus.downloading, + progress: percentage, + speedMBps: speedMBps, + ); final mbReceived = bytesReceived / (1024 * 1024); final mbTotal = bytesTotal / (1024 * 1024); @@ -546,6 +567,41 @@ class DownloadQueueNotifier extends Notifier { } } + if (progressUpdates.isNotEmpty) { + var updatedItems = currentItems; + bool changed = false; + + for (final entry in progressUpdates.entries) { + final index = itemIndexById[entry.key]; + if (index == null) continue; + final current = updatedItems[index]; + if (current.status == DownloadStatus.skipped || + current.status == DownloadStatus.completed || + current.status == DownloadStatus.failed) { + continue; + } + final update = entry.value; + final next = current.copyWith( + status: update.status, + progress: update.progress, + speedMBps: update.speedMBps ?? current.speedMBps, + ); + if (current.status != next.status || + current.progress != next.progress || + current.speedMBps != next.speedMBps) { + if (!changed) { + updatedItems = List.from(updatedItems); + changed = true; + } + updatedItems[index] = next; + } + } + + if (changed) { + state = state.copyWith(items: updatedItems); + } + } + if (hasFinalizingItem && finalizingTrackName != null) { _notificationService.showDownloadFinalizing( trackName: finalizingTrackName, @@ -651,6 +707,20 @@ class DownloadQueueNotifier extends Notifier { } } + Future _ensureDirExists(String path, {String? label}) async { + if (_ensuredDirs.contains(path)) return; + final dir = Directory(path); + if (!await dir.exists()) { + await dir.create(recursive: true); + if (label != null) { + _log.d('Created $label: $path'); + } else { + _log.d('Created folder: $path'); + } + } + _ensuredDirs.add(path); + } + void setOutputDir(String dir) { state = state.copyWith(outputDir: dir); } @@ -665,11 +735,7 @@ class DownloadQueueNotifier extends Notifier { if (isSingle) { final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; - final dir = Directory(singlesPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - _log.d('Created Singles folder: $singlesPath'); - } + await _ensureDirExists(singlesPath, label: 'Singles folder'); return singlesPath; } else { final albumName = _sanitizeFolderName(track.albumName); @@ -693,11 +759,7 @@ class DownloadQueueNotifier extends Notifier { albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; } - final dir = Directory(albumPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - _log.d('Created Album folder: $albumPath'); - } + await _ensureDirExists(albumPath, label: 'Album folder'); return albumPath; } } @@ -725,11 +787,7 @@ class DownloadQueueNotifier extends Notifier { if (subPath.isNotEmpty) { final fullPath = '$baseDir${Platform.pathSeparator}$subPath'; - final dir = Directory(fullPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - _log.d('Created folder: $fullPath'); - } + await _ensureDirExists(fullPath); return fullPath; } @@ -824,21 +882,32 @@ class DownloadQueueNotifier extends Notifier { String? error, DownloadErrorType? errorType, }) { - final items = state.items.map((item) { - if (item.id == id) { - return item.copyWith( - status: status, - progress: progress ?? item.progress, - speedMBps: speedMBps ?? item.speedMBps, - filePath: filePath, - error: error, - errorType: errorType, - ); - } - return item; - }).toList(); + final items = state.items; + final index = items.indexWhere((item) => item.id == id); + if (index == -1) return; - state = state.copyWith(items: items); + final current = items[index]; + final next = current.copyWith( + status: status, + progress: progress ?? current.progress, + speedMBps: speedMBps ?? current.speedMBps, + filePath: filePath, + error: error, + errorType: errorType, + ); + + if (current.status == next.status && + current.progress == next.progress && + current.speedMBps == next.speedMBps && + current.filePath == next.filePath && + current.error == next.error && + current.errorType == next.errorType) { + return; + } + + final updatedItems = List.from(items); + updatedItems[index] = next; + state = state.copyWith(items: updatedItems); if (status == DownloadStatus.completed || status == DownloadStatus.failed || @@ -848,9 +917,11 @@ class DownloadQueueNotifier extends Notifier { } void updateProgress(String id, double progress, {double? speedMBps}) { - final item = state.items.where((i) => i.id == id).firstOrNull; - if (item == null || - item.status == DownloadStatus.skipped || + final items = state.items; + final index = items.indexWhere((i) => i.id == id); + if (index == -1) return; + final item = items[index]; + if (item.status == DownloadStatus.skipped || item.status == DownloadStatus.completed || item.status == DownloadStatus.failed) { return; @@ -2121,3 +2192,22 @@ final downloadQueueProvider = NotifierProvider( DownloadQueueNotifier.new, ); + +class DownloadQueueLookup { + final Map byTrackId; + + DownloadQueueLookup._(this.byTrackId); + + factory DownloadQueueLookup.fromItems(List items) { + final map = {}; + for (final item in items) { + map.putIfAbsent(item.track.id, () => item); + } + return DownloadQueueLookup._(map); + } +} + +final downloadQueueLookupProvider = Provider((ref) { + final items = ref.watch(downloadQueueProvider.select((s) => s.items)); + return DownloadQueueLookup.fromItems(items); +}); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 4534d400..cb0fad0b 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -488,6 +488,9 @@ class TrackNotifier extends Notifier { /// Set search text state for back button handling void setSearchText(bool hasText) { + if (state.hasSearchText == hasText) { + return; + } state = state.copyWith(hasSearchText: hasText); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 0e9125a3..5eede561 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:palette_generator/palette_generator.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -283,10 +284,11 @@ class _AlbumScreenState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: widget.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -521,9 +523,9 @@ class _AlbumTrackItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final queueItem = ref.watch(downloadQueueProvider.select((state) { - return state.items.where((item) => item.track.id == track.id).firstOrNull; - })); + final queueItem = ref.watch( + downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + ); final isInHistory = ref.watch(downloadHistoryProvider.select((state) { return state.isDownloaded(track.id); @@ -545,8 +547,8 @@ class _AlbumTrackItem extends ConsumerWidget { margin: const EdgeInsets.symmetric(vertical: 2), child: ListTile( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - leading: track.coverUrl != null - ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96)) +leading: track.coverUrl != null + ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance)) : Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 4e121f6a..7a7a35a4 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:intl/intl.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -355,12 +356,13 @@ return SliverAppBar( background: Stack( fit: StackFit.expand, children: [ - if (hasValidImage) +if (hasValidImage) CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, alignment: Alignment.topCenter, // Show top of image (faces) memCacheWidth: 800, + cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( color: colorScheme.surfaceContainerHighest, ), @@ -479,9 +481,9 @@ return SliverAppBar( /// Build a single popular track item with dynamic download status Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { - final queueItem = ref.watch(downloadQueueProvider.select((state) { - return state.items.where((item) => item.track.id == track.id).firstOrNull; - })); + final queueItem = ref.watch( + downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + ); final isInHistory = ref.watch(downloadHistoryProvider.select((state) { return state.isDownloaded(track.id); @@ -515,12 +517,13 @@ return SliverAppBar( ClipRRect( borderRadius: BorderRadius.circular(4), child: track.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, + cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( width: 48, height: 48, @@ -751,12 +754,13 @@ return SliverAppBar( ClipRRect( borderRadius: BorderRadius.circular(8), child: album.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: album.coverUrl!, width: 140, height: 140, fit: BoxFit.cover, memCacheWidth: 280, + cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( width: 140, height: 140, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 477e4abc..830dd8c3 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -103,24 +104,15 @@ class _DownloadedAlbumScreenState extends ConsumerState { }); } - /// Get unique disc numbers from tracks (sorted) - List _getDiscNumbers(List tracks) { - final discNumbers = tracks - .map((t) => t.discNumber ?? 1) - .toSet() - .toList() - ..sort(); - return discNumbers; - } - - /// Check if album has multiple discs - bool _hasMultipleDiscs(List tracks) { - return _getDiscNumbers(tracks).length > 1; - } - - /// Get tracks for a specific disc - List _getTracksForDisc(List tracks, int discNumber) { - return tracks.where((t) => (t.discNumber ?? 1) == discNumber).toList(); + Map> _groupTracksByDisc( + List tracks, + ) { + final discMap = >{}; + for (final track in tracks) { + final discNumber = track.discNumber ?? 1; + discMap.putIfAbsent(discNumber, () => []).add(track); + } + return discMap; } void _enterSelectionMode(String itemId) { @@ -223,6 +215,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { } void _navigateToMetadataScreen(DownloadHistoryItem item) { + _precacheCover(item.coverUrl); Navigator.push(context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -231,6 +224,17 @@ class _DownloadedAlbumScreenState extends ConsumerState { )); } + void _precacheCover(String? url) { + if (url == null || url.isEmpty) return; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return; + } + precacheImage( + CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance), + context, + ); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -368,10 +372,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: widget.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -501,8 +506,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { } Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { - // Check if album has multiple discs - if (!_hasMultipleDiscs(tracks)) { + final discMap = _groupTracksByDisc(tracks); + + // Single disc - use simple list + if (discMap.length <= 1) { // Single disc - use simple list return SliverList( delegate: SliverChildBuilderDelegate( @@ -519,12 +526,12 @@ class _DownloadedAlbumScreenState extends ConsumerState { } // Multiple discs - build list with separators - final discNumbers = _getDiscNumbers(tracks); + final discNumbers = discMap.keys.toList()..sort(); final List children = []; for (final discNumber in discNumbers) { - final discTracks = _getTracksForDisc(tracks, discNumber); - if (discTracks.isEmpty) continue; + final discTracks = discMap[discNumber]; + if (discTracks == null || discTracks.isEmpty) continue; // Add disc separator children.add(_buildDiscSeparator(context, colorScheme, discNumber)); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 65c61116..00cd98a6 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -210,11 +211,12 @@ class _HomeScreenState extends ConsumerState { if (state.coverUrl != null) ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: state.coverUrl!, width: 80, height: 80, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container( width: 80, height: 80, @@ -281,11 +283,12 @@ class _HomeScreenState extends ConsumerState { leading: track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, ), ) : Container( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index df6926fc..c692f6db 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; @@ -637,13 +638,14 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ClipRRect( borderRadius: BorderRadius.circular(12), child: item.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, width: 100, height: 100, fit: BoxFit.cover, memCacheWidth: 200, memCacheHeight: 200, + cacheManager: CoverCacheManager.instance, ) : Container( width: 100, @@ -845,12 +847,13 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ClipRRect( borderRadius: BorderRadius.circular(item.type == RecentAccessType.artist ? 28 : 4), child: item.imageUrl != null && item.imageUrl!.isNotEmpty - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.imageUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, + cacheManager: CoverCacheManager.instance, errorWidget: (context, url, error) => Container( width: 56, height: 56, @@ -977,6 +980,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } void _navigateToMetadataScreen(DownloadHistoryItem item) { + _precacheCover(item.coverUrl); Navigator.push(context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -985,6 +989,17 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } + void _precacheCover(String? url) { + if (url == null || url.isEmpty) return; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return; + } + precacheImage( + CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance), + context, + ); + } + /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || @@ -1059,10 +1074,28 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } - final realTracks = tracks.where((t) => !t.isCollection).toList(); - final albumItems = tracks.where((t) => t.isAlbumItem).toList(); - final playlistItems = tracks.where((t) => t.isPlaylistItem).toList(); - final artistItems = tracks.where((t) => t.isArtistItem).toList(); + final realTracks = []; + final realTrackIndexes = []; + final albumItems = []; + final playlistItems = []; + final artistItems = []; + + for (int i = 0; i < tracks.length; i++) { + final track = tracks[i]; + if (!track.isCollection) { + realTracks.add(track); + realTrackIndexes.add(i); + } + if (track.isAlbumItem) { + albumItems.add(track); + } + if (track.isPlaylistItem) { + playlistItems.add(track); + } + if (track.isArtistItem) { + artistItems.add(track); + } + } return [ if (error != null) @@ -1205,9 +1238,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient _TrackItemWithStatus( key: ValueKey(realTracks[i].id), track: realTracks[i], - index: tracks.indexOf(realTracks[i]), // Use original index for download + index: realTrackIndexes[i], showDivider: i < realTracks.length - 1, - onDownload: () => _downloadTrack(tracks.indexOf(realTracks[i])), + onDownload: () => _downloadTrack(realTrackIndexes[i]), ), ], ), @@ -1267,11 +1300,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), child: ClipOval( child: hasValidImage - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: artist.imageUrl!, fit: BoxFit.cover, memCacheWidth: 200, memCacheHeight: 200, + cacheManager: CoverCacheManager.instance, errorWidget: (context, url, error) => Icon( Icons.person, color: colorScheme.onSurfaceVariant, @@ -1701,9 +1735,9 @@ class _TrackItemWithStatus extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final queueItem = ref.watch(downloadQueueProvider.select((state) { - return state.items.where((item) => item.track.id == track.id).firstOrNull; - })); + final queueItem = ref.watch( + downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + ); final isInHistory = ref.watch(downloadHistoryProvider.select((state) { return state.isDownloaded(track.id); @@ -1750,13 +1784,14 @@ class _TrackItemWithStatus extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: track.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: track.coverUrl!, width: thumbWidth, height: thumbHeight, fit: BoxFit.cover, memCacheWidth: (thumbWidth * 2).toInt(), memCacheHeight: (thumbHeight * 2).toInt(), + cacheManager: CoverCacheManager.instance, ) : Container( width: thumbWidth, @@ -1929,13 +1964,14 @@ class _CollectionItemWidget extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(isArtist ? 28 : 10), child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, ) : Container( width: 56, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index aa76f696..a62af731 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:palette_generator/palette_generator.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -164,10 +165,11 @@ class _PlaylistScreenState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: widget.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -323,9 +325,9 @@ class _PlaylistTrackItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final queueItem = ref.watch(downloadQueueProvider.select((state) { - return state.items.where((item) => item.track.id == track.id).firstOrNull; - })); + final queueItem = ref.watch( + downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + ); final isInHistory = ref.watch(downloadHistoryProvider.select((state) { return state.isDownloaded(track.id); @@ -347,8 +349,8 @@ class _PlaylistTrackItem extends ConsumerWidget { margin: const EdgeInsets.symmetric(vertical: 2), child: ListTile( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - leading: track.coverUrl != null - ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96)) +leading: track.coverUrl != null + ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance)) : Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), diff --git a/lib/screens/queue_screen.dart b/lib/screens/queue_screen.dart index cb604cd4..aa38fad7 100644 --- a/lib/screens/queue_screen.dart +++ b/lib/screens/queue_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -74,11 +75,12 @@ class QueueScreen extends ConsumerWidget { leading: item.track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: item.track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, ), ) : Container( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 2bf54f5f..1017cc29 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -31,6 +32,20 @@ class _GroupedAlbum { String get key => '$albumName|$artistName'; } +class _HistoryStats { + final Map albumCounts; + final List<_GroupedAlbum> groupedAlbums; + final int albumCount; + final int singleTracks; + + const _HistoryStats({ + required this.albumCounts, + required this.groupedAlbums, + required this.albumCount, + required this.singleTracks, + }); +} + class QueueTab extends ConsumerStatefulWidget { final PageController? parentPageController; final int parentPageIndex; @@ -234,6 +249,17 @@ class _QueueTabState extends ConsumerState { } } + void _precacheCover(String? url) { + if (url == null || url.isEmpty) return; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return; + } + precacheImage( + CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance), + context, + ); + } + void _navigateToMetadataScreen(DownloadItem item) { final historyItem = ref .read(downloadHistoryProvider) @@ -252,6 +278,7 @@ class _QueueTabState extends ConsumerState { ), ); + _precacheCover(historyItem.coverUrl); Navigator.push( context, PageRouteBuilder( @@ -266,6 +293,7 @@ class _QueueTabState extends ConsumerState { } void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { + _precacheCover(item.coverUrl); Navigator.push( context, PageRouteBuilder( @@ -285,15 +313,10 @@ class _QueueTabState extends ConsumerState { List _filterHistoryItems( List items, String filterMode, + Map albumCounts, ) { if (filterMode == 'all') return items; - final albumCounts = {}; - for (final item in items) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; - albumCounts[key] = (albumCounts[key] ?? 0) + 1; - } - switch (filterMode) { case 'albums': return items.where((item) { @@ -312,82 +335,56 @@ class _QueueTabState extends ConsumerState { } } - /// Count albums vs singles for filter chips - Map _countAlbumsAndSingles(List items) { + _HistoryStats _buildHistoryStats(List items) { final albumCounts = {}; + final albumMap = >{}; for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumCounts[key] = (albumCounts[key] ?? 0) + 1; + albumMap.putIfAbsent(key, () => []).add(item); } - int albumTracks = 0; int singleTracks = 0; - for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; - if ((albumCounts[key] ?? 0) > 1) { - albumTracks++; - } else { + if ((albumCounts[key] ?? 0) <= 1) { singleTracks++; } } - return {'albums': albumTracks, 'singles': singleTracks}; - } + final groupedAlbums = <_GroupedAlbum>[]; + albumMap.forEach((_, tracks) { + if (tracks.length <= 1) return; + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); - /// Group history items by album (for Albums filter view) - List<_GroupedAlbum> _groupByAlbum(List items) { - final albumMap = >{}; - - for (final item in items) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; - albumMap.putIfAbsent(key, () => []).add(item); - } - - final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map( - (e) { - final tracks = e.value; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - return _GroupedAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverUrl: tracks.first.coverUrl, - tracks: tracks, - latestDownload: tracks - .map((t) => t.downloadedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ); - }, - ).toList(); + groupedAlbums.add(_GroupedAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverUrl: tracks.first.coverUrl, + tracks: tracks, + latestDownload: tracks + .map((t) => t.downloadedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + )); + }); groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); - return groupedAlbums; - } - - /// Count unique albums (for filter chip badge) - int _countUniqueAlbums(List items) { - final albumKeys = {}; - for (final item in items) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; - albumKeys.add(key); + int albumCount = 0; + for (final count in albumCounts.values) { + if (count > 1) albumCount++; } - int count = 0; - for (final key in albumKeys) { - final trackCount = items - .where( - (i) => '${i.albumName}|${i.albumArtist ?? i.artistName}' == key, - ) - .length; - if (trackCount > 1) count++; - } - return count; + return _HistoryStats( + albumCounts: albumCounts, + groupedAlbums: groupedAlbums, + albumCount: albumCount, + singleTracks: singleTracks, + ); } void _navigateToDownloadedAlbum(_GroupedAlbum album) { @@ -435,11 +432,10 @@ class _QueueTabState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - final groupedAlbums = _groupByAlbum(allHistoryItems); - - final counts = _countAlbumsAndSingles(allHistoryItems); - final albumCount = _countUniqueAlbums(allHistoryItems); - final singleCount = counts['singles'] ?? 0; + final historyStats = _buildHistoryStats(allHistoryItems); + final groupedAlbums = historyStats.groupedAlbums; + final albumCount = historyStats.albumCount; + final singleCount = historyStats.singleTracks; final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -679,6 +675,7 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + albumCounts: historyStats.albumCounts, ), _buildFilterContent( context: context, @@ -688,6 +685,7 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + albumCounts: historyStats.albumCounts, ), _buildFilterContent( context: context, @@ -697,6 +695,7 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + albumCounts: historyStats.albumCounts, ), ], ), @@ -713,7 +712,11 @@ class _QueueTabState extends ConsumerState { child: _buildSelectionBottomBar( context, colorScheme, - _filterHistoryItems(allHistoryItems, historyFilterMode), + _filterHistoryItems( + allHistoryItems, + historyFilterMode, + historyStats.albumCounts, + ), bottomPadding, ), ), @@ -731,8 +734,10 @@ class _QueueTabState extends ConsumerState { required String historyViewMode, required List queueItems, required List<_GroupedAlbum> groupedAlbums, + required Map albumCounts, }) { - final historyItems = _filterHistoryItems(allHistoryItems, filterMode); + final historyItems = + _filterHistoryItems(allHistoryItems, filterMode, albumCounts); return CustomScrollView( slivers: [ @@ -943,13 +948,14 @@ class _QueueTabState extends ConsumerState { ClipRRect( borderRadius: BorderRadius.circular(12), child: album.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: album.coverUrl!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, memCacheWidth: 300, memCacheHeight: 300, + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -1245,13 +1251,14 @@ class _QueueTabState extends ConsumerState { return item.track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: item.track.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, ), ) : Container( @@ -1404,11 +1411,12 @@ class _QueueTabState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(8), child: item.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, fit: BoxFit.cover, memCacheWidth: 200, memCacheHeight: 200, + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -1613,13 +1621,14 @@ class _QueueTabState extends ConsumerState { item.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: item.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, ), ) : Container( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 39789ddf..88377d51 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -135,11 +136,12 @@ class _SearchScreenState extends ConsumerState { leading: track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, ), ) : Container( diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 2840df6a..3df2dd2c 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -333,11 +334,12 @@ class _ContributorItem extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(12), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: 'https://github.com/$githubUsername.png', width: 40, height: 40, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( width: 40, height: 40, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index d360f196..4e236f0c 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; @@ -32,6 +33,22 @@ class _TrackMetadataScreenState extends ConsumerState { Color? _dominantColor; bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); + static final RegExp _lrcTimestampPattern = + RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); + static const List _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; String? _normalizeOptionalString(String? value) { if (value == null) return null; @@ -64,17 +81,23 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _extractDominantColor() async { - if (widget.item.coverUrl == null) return; + final coverUrl = widget.item.coverUrl; + if (coverUrl == null || coverUrl.isEmpty) return; + if (!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://')) { + return; + } try { final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(widget.item.coverUrl!), - maximumColorCount: 16, + CachedNetworkImageProvider(coverUrl), + size: const Size(128, 128), + maximumColorCount: 12, ); - if (mounted) { + final nextColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + if (mounted && nextColor != _dominantColor) { setState(() { - _dominantColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; + _dominantColor = nextColor; }); } } catch (_) { @@ -87,26 +110,26 @@ class _TrackMetadataScreenState extends ConsumerState { if (filePath.startsWith('EXISTS:')) { filePath = filePath.substring(7); } - - final file = File(filePath); - final exists = await file.exists(); + + bool exists = false; int? size; - - if (exists) { - try { - size = await file.length(); - } catch (_) {} - } - - if (mounted) { + try { + final stat = await FileStat.stat(filePath); + exists = stat.type != FileSystemEntityType.notFound; + if (exists) { + size = stat.size; + } + } catch (_) {} + + if (mounted && (exists != _fileExists || size != _fileSize)) { setState(() { _fileExists = exists; _fileSize = size; }); - - if (exists) { - _fetchLyrics(); - } + } + + if (mounted && exists && _lyrics == null && !_lyricsLoading) { + _fetchLyrics(); } } @@ -282,10 +305,11 @@ class _TrackMetadataScreenState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: item.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), + cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container( color: colorScheme.surfaceContainerHighest, child: Icon( @@ -909,10 +933,9 @@ class _TrackMetadataScreenState extends ConsumerState { String _cleanLrcForDisplay(String lrc) { final lines = lrc.split('\n'); final cleanLines = []; - final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); for (final line in lines) { - final cleanLine = line.replaceAll(timestampPattern, '').trim(); + final cleanLine = line.replaceAll(_lrcTimestampPattern, '').trim(); if (cleanLine.isNotEmpty) { cleanLines.add(cleanLine); } @@ -1093,9 +1116,7 @@ class _TrackMetadataScreenState extends ConsumerState { } String _formatFullDate(DateTime date) { - final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return '${date.day} ${months[date.month - 1]} ${date.year}, ' + return '${date.day} ${_months[date.month - 1]} ${date.year}, ' '${date.hour.toString().padLeft(2, '0')}:' '${date.minute.toString().padLeft(2, '0')}'; } diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart new file mode 100644 index 00000000..d508d251 --- /dev/null +++ b/lib/services/cover_cache_manager.dart @@ -0,0 +1,114 @@ +import 'dart:io'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +/// Persistent cache manager for album/track cover images. +/// +/// Unlike the default cache manager which stores in temp directory +/// (can be cleared by system anytime), this stores in app support +/// directory which persists across app restarts. +class CoverCacheManager { + static const String _cacheKey = 'coverImageCache'; + static const int _maxCacheObjects = 1000; + static const Duration _maxCacheAge = Duration(days: 365); + + static CacheManager? _instance; + static bool _initialized = false; + + /// Get the singleton cache manager instance. + /// Must call [initialize] before using this. + static CacheManager get instance { + if (!_initialized || _instance == null) { + throw StateError( + 'CoverCacheManager not initialized. Call CoverCacheManager.initialize() first.', + ); + } + return _instance!; + } + + /// Check if cache manager is initialized + static bool get isInitialized => _initialized && _instance != null; + + /// Initialize the cache manager with persistent storage path. + /// Call this once during app startup (in main.dart). + static Future initialize() async { + if (_initialized) return; + + final appDir = await getApplicationSupportDirectory(); + final cachePath = p.join(appDir.path, 'cover_cache'); + + // Ensure cache directory exists + await Directory(cachePath).create(recursive: true); + + _instance = CacheManager( + Config( + _cacheKey, + stalePeriod: _maxCacheAge, + maxNrOfCacheObjects: _maxCacheObjects, + repo: JsonCacheInfoRepository(databaseName: _cacheKey), + fileSystem: IOFileSystem(cachePath), + fileService: HttpFileService(), + ), + ); + + _initialized = true; + } + + /// Clear all cached cover images. + /// Returns the number of files deleted. + static Future clearCache() async { + if (!_initialized || _instance == null) return; + await _instance!.emptyCache(); + } + + /// Get cache statistics + static Future getStats() async { + if (!_initialized) { + return const CacheStats(fileCount: 0, totalSizeBytes: 0); + } + + final appDir = await getApplicationSupportDirectory(); + final cacheDir = Directory(p.join(appDir.path, 'cover_cache')); + + if (!await cacheDir.exists()) { + return const CacheStats(fileCount: 0, totalSizeBytes: 0); + } + + int fileCount = 0; + int totalSize = 0; + + await for (final entity in cacheDir.list(recursive: true)) { + if (entity is File) { + fileCount++; + totalSize += await entity.length(); + } + } + + return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize); + } +} + +/// Statistics about the cover image cache +class CacheStats { + final int fileCount; + final int totalSizeBytes; + + const CacheStats({ + required this.fileCount, + required this.totalSizeBytes, + }); + + /// Get human-readable size string + String get formattedSize { + if (totalSizeBytes < 1024) { + return '$totalSizeBytes B'; + } else if (totalSizeBytes < 1024 * 1024) { + return '${(totalSizeBytes / 1024).toStringAsFixed(1)} KB'; + } else if (totalSizeBytes < 1024 * 1024 * 1024) { + return '${(totalSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } else { + return '${(totalSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + } +} diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart new file mode 100644 index 00000000..6c01e415 --- /dev/null +++ b/lib/widgets/cached_cover_image.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; + +/// A wrapper around CachedNetworkImage that uses persistent cache storage. +/// +/// This ensures cover images are cached to disk and persist across app restarts, +/// instead of being stored in the temporary directory that can be cleared by the OS. +class CachedCoverImage extends StatelessWidget { + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + final int? memCacheWidth; + final int? memCacheHeight; + final Widget Function(BuildContext, String, Object)? errorWidget; + final Widget Function(BuildContext, String)? placeholder; + final BorderRadius? borderRadius; + + const CachedCoverImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.memCacheWidth, + this.memCacheHeight, + this.errorWidget, + this.placeholder, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final image = CachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: fit, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, + cacheManager: CoverCacheManager.isInitialized + ? CoverCacheManager.instance + : null, + errorWidget: errorWidget, + placeholder: placeholder, + ); + + if (borderRadius != null) { + return ClipRRect( + borderRadius: borderRadius!, + child: image, + ); + } + + return image; + } +} + +/// Provider for CachedNetworkImageProvider that uses persistent cache. +/// Use this for precacheImage() calls. +CachedNetworkImageProvider cachedCoverImageProvider(String url) { + return CachedNetworkImageProvider( + url, + cacheManager: CoverCacheManager.isInitialized + ? CoverCacheManager.instance + : null, + ); +} diff --git a/pubspec.lock b/pubspec.lock index a2b61026..5233bceb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -327,7 +327,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" @@ -662,7 +662,7 @@ packages: source: hosted version: "0.3.3+7" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index 9825b60a..ced54854 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,9 +22,10 @@ dependencies: # Navigation go_router: ^17.0.1 - # Storage & Persistence +# Storage & Persistence shared_preferences: ^2.5.3 path_provider: ^2.1.5 + path: ^1.9.0 # HTTP & Network http: ^1.6.0 @@ -33,6 +34,7 @@ dependencies: # UI Components cupertino_icons: ^1.0.8 cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 flutter_svg: ^2.1.0 # Material Expressive 3 / Dynamic Color diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index da99f5d8..94fd2e1b 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -22,17 +22,19 @@ dependencies: # Navigation go_router: ^17.0.1 - # Storage & Persistence +# Storage & Persistence shared_preferences: ^2.5.3 path_provider: ^2.1.5 + path: ^1.9.0 # HTTP & Network http: ^1.6.0 dio: ^5.8.0 - # UI Components +# UI Components cupertino_icons: ^1.0.8 cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 flutter_svg: ^2.1.0 # Material Expressive 3 / Dynamic Color From ec314eb4794a1394a276801dc69ded8595f6f488 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 23:14:33 +0700 Subject: [PATCH 41/48] fix: store cache database in persistent directory - Add path parameter to JsonCacheInfoRepository - Add fallback to DefaultCacheManager if initialization fails - Add debug logging for troubleshooting - Fix issue where cache database was in temp dir while files in persistent --- lib/services/cover_cache_manager.dart | 68 ++++++++++++++++----------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index d508d251..ed2e215f 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; @@ -15,14 +16,15 @@ class CoverCacheManager { static CacheManager? _instance; static bool _initialized = false; + static String? _cachePath; /// Get the singleton cache manager instance. /// Must call [initialize] before using this. static CacheManager get instance { if (!_initialized || _instance == null) { - throw StateError( - 'CoverCacheManager not initialized. Call CoverCacheManager.initialize() first.', - ); + // Fallback to default cache manager if not initialized + debugPrint('CoverCacheManager: Not initialized, using DefaultCacheManager'); + return DefaultCacheManager(); } return _instance!; } @@ -35,24 +37,33 @@ class CoverCacheManager { static Future initialize() async { if (_initialized) return; - final appDir = await getApplicationSupportDirectory(); - final cachePath = p.join(appDir.path, 'cover_cache'); - - // Ensure cache directory exists - await Directory(cachePath).create(recursive: true); + try { + final appDir = await getApplicationSupportDirectory(); + _cachePath = p.join(appDir.path, 'cover_cache'); + + // Ensure cache directory exists + await Directory(_cachePath!).create(recursive: true); + + debugPrint('CoverCacheManager: Initializing at $_cachePath'); - _instance = CacheManager( - Config( - _cacheKey, - stalePeriod: _maxCacheAge, - maxNrOfCacheObjects: _maxCacheObjects, - repo: JsonCacheInfoRepository(databaseName: _cacheKey), - fileSystem: IOFileSystem(cachePath), - fileService: HttpFileService(), - ), - ); - - _initialized = true; + _instance = CacheManager( + Config( + _cacheKey, + stalePeriod: _maxCacheAge, + maxNrOfCacheObjects: _maxCacheObjects, + // Store database in the same persistent directory + repo: JsonCacheInfoRepository(databaseName: _cacheKey, path: _cachePath), + fileSystem: IOFileSystem(_cachePath!), + fileService: HttpFileService(), + ), + ); + + _initialized = true; + debugPrint('CoverCacheManager: Initialized successfully'); + } catch (e) { + debugPrint('CoverCacheManager: Failed to initialize: $e'); + // Will fallback to DefaultCacheManager + } } /// Clear all cached cover images. @@ -64,12 +75,11 @@ class CoverCacheManager { /// Get cache statistics static Future getStats() async { - if (!_initialized) { + if (!_initialized || _cachePath == null) { return const CacheStats(fileCount: 0, totalSizeBytes: 0); } - final appDir = await getApplicationSupportDirectory(); - final cacheDir = Directory(p.join(appDir.path, 'cover_cache')); + final cacheDir = Directory(_cachePath!); if (!await cacheDir.exists()) { return const CacheStats(fileCount: 0, totalSizeBytes: 0); @@ -78,11 +88,15 @@ class CoverCacheManager { int fileCount = 0; int totalSize = 0; - await for (final entity in cacheDir.list(recursive: true)) { - if (entity is File) { - fileCount++; - totalSize += await entity.length(); + try { + await for (final entity in cacheDir.list(recursive: true)) { + if (entity is File) { + fileCount++; + totalSize += await entity.length(); + } } + } catch (e) { + debugPrint('CoverCacheManager: Error getting stats: $e'); } return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize); From 6c8813c9de8c7330467b2f3ac3b016685a304e80 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 23:22:53 +0700 Subject: [PATCH 42/48] fix: ensure CoverCacheManager initializes before app renders - Move CoverCacheManager.initialize() to run BEFORE other services - Add debug log to confirm initialization status - Fixes race condition where widgets render before cache is ready --- lib/main.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index ff3ec625..411235ea 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,10 +11,15 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + + // Initialize services - CoverCacheManager MUST complete before app starts + await CoverCacheManager.initialize(); + debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}'); + + // These can run in parallel await Future.wait([ NotificationService().initialize(), ShareIntentService().initialize(), - CoverCacheManager.initialize(), ]); runApp( From 8e9d0c3e9ab2cb95612dcc418590aa77cc147272 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 23:26:10 +0700 Subject: [PATCH 43/48] fix: use path only for JsonCacheInfoRepository JsonCacheInfoRepository assertion requires either path OR databaseName, not both. Using path only to ensure database is stored in persistent directory. --- lib/services/cover_cache_manager.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index ed2e215f..37d2fc3f 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -51,8 +51,8 @@ class CoverCacheManager { _cacheKey, stalePeriod: _maxCacheAge, maxNrOfCacheObjects: _maxCacheObjects, - // Store database in the same persistent directory - repo: JsonCacheInfoRepository(databaseName: _cacheKey, path: _cachePath), + // Use path only (not databaseName) to store database in persistent directory + repo: JsonCacheInfoRepository(path: _cachePath), fileSystem: IOFileSystem(_cachePath!), fileService: HttpFileService(), ), From 03027813c1932d9b84b0ec67ff2b2d2aaa969267 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 02:10:10 +0700 Subject: [PATCH 44/48] chore: cleanup unused code and dead imports --- go_backend/amazon.go | 25 +-- go_backend/cover.go | 17 +- go_backend/deezer.go | 40 +--- go_backend/duplicate.go | 6 - go_backend/exports.go | 157 ++++------------ go_backend/extension_manager.go | 26 +-- go_backend/extension_manifest.go | 20 -- go_backend/extension_runtime.go | 29 +-- go_backend/extension_runtime_auth.go | 29 +-- go_backend/extension_runtime_ffmpeg.go | 6 - go_backend/extension_runtime_file.go | 40 +--- go_backend/extension_runtime_http.go | 16 -- go_backend/extension_runtime_storage.go | 4 - go_backend/extension_runtime_utils.go | 3 - go_backend/extension_settings.go | 4 - go_backend/extension_store.go | 2 - go_backend/filename.go | 4 - go_backend/httputil.go | 46 ----- go_backend/logbuffer.go | 4 - go_backend/lyrics.go | 35 +--- go_backend/metadata.go | 31 +-- go_backend/parallel.go | 40 +--- go_backend/progress.go | 24 +-- go_backend/qobuz.go | 42 +---- go_backend/ratelimit.go | 8 - go_backend/romaji.go | 11 -- go_backend/songlink.go | 15 -- go_backend/spotify.go | 73 ++------ go_backend/tidal.go | 176 ++---------------- lib/main.dart | 2 - lib/models/download_item.dart | 15 +- lib/models/settings.dart | 86 ++++----- lib/models/theme_settings.dart | 6 - lib/models/track.dart | 13 +- lib/providers/download_queue_provider.dart | 99 +++------- lib/providers/extension_provider.dart | 55 ++---- lib/providers/recent_access_provider.dart | 2 - lib/providers/settings_provider.dart | 3 - lib/providers/store_provider.dart | 7 - lib/providers/theme_provider.dart | 1 - lib/providers/track_provider.dart | 19 +- lib/screens/album_screen.dart | 13 +- lib/screens/artist_screen.dart | 4 - lib/screens/downloaded_album_screen.dart | 4 - lib/screens/home_tab.dart | 44 +---- lib/screens/main_shell.dart | 6 - lib/screens/playlist_screen.dart | 2 - lib/screens/queue_tab.dart | 10 - lib/screens/settings/about_page.dart | 6 +- .../settings/appearance_settings_page.dart | 6 +- .../settings/download_settings_page.dart | 2 +- lib/screens/setup_screen.dart | 2 +- lib/screens/store_tab.dart | 2 +- lib/screens/track_metadata_screen.dart | 10 +- lib/services/cover_cache_manager.dart | 6 - lib/services/csv_import_service.dart | 4 - lib/services/ffmpeg_service.dart | 19 +- lib/services/platform_bridge.dart | 124 +----------- lib/services/share_intent_service.dart | 10 - lib/theme/app_theme.dart | 11 -- lib/utils/logger.dart | 7 - lib/widgets/cached_cover_image.dart | 6 - 62 files changed, 213 insertions(+), 1326 deletions(-) diff --git a/go_backend/amazon.go b/go_backend/amazon.go index cb130bd4..c16d30eb 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -17,13 +17,12 @@ import ( "time" ) -// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC) type AmazonDownloader struct { client *http.Client - regions []string // us, eu regions for DoubleDouble service - lastAPICallTime time.Time // Rate limiting: track last API call - apiCallCount int // Rate limiting: counter per minute - apiCallResetTime time.Time // Rate limiting: reset time + regions []string + lastAPICallTime time.Time + apiCallCount int + apiCallResetTime time.Time } var ( @@ -38,7 +37,6 @@ type DoubleDoubleSubmitResponse struct { ID string `json:"id"` } -// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint type DoubleDoubleStatusResponse struct { Status string `json:"status"` FriendlyStatus string `json:"friendlyStatus"` @@ -49,7 +47,6 @@ type DoubleDoubleStatusResponse struct { } `json:"current"` } -// amazonArtistsMatch checks if the artist names are similar enough func amazonArtistsMatch(expectedArtist, foundArtist string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist)) @@ -90,7 +87,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool { return false } -// amazonIsASCIIString checks if a string contains only ASCII characters func amazonIsASCIIString(s string) bool { for _, r := range s { if r > 127 { @@ -100,7 +96,6 @@ func amazonIsASCIIString(s string) bool { return true } -// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse) func NewAmazonDownloader() *AmazonDownloader { amazonDownloaderOnce.Do(func() { globalAmazonDownloader = &AmazonDownloader{ @@ -113,7 +108,6 @@ func NewAmazonDownloader() *AmazonDownloader { } // waitForRateLimit implements rate limiting similar to PC version -// Max 9 requests per minute with 7 second delay between requests func (a *AmazonDownloader) waitForRateLimit() { amazonRateLimitMu.Lock() defer amazonRateLimitMu.Unlock() @@ -125,7 +119,6 @@ func (a *AmazonDownloader) waitForRateLimit() { a.apiCallResetTime = now } - // If we've hit the limit (9 requests per minute), wait until next minute if a.apiCallCount >= 9 { waitTime := time.Minute - now.Sub(a.apiCallResetTime) if waitTime > 0 { @@ -136,7 +129,6 @@ func (a *AmazonDownloader) waitForRateLimit() { } } - // Add delay between requests (7 seconds like PC version) if !a.lastAPICallTime.IsZero() { timeSinceLastCall := now.Sub(a.lastAPICallTime) minDelay := 7 * time.Second @@ -151,7 +143,6 @@ func (a *AmazonDownloader) waitForRateLimit() { a.apiCallCount++ } -// GetAvailableAPIs returns list of available DoubleDouble regions // Uses same service as PC version (doubledouble.top) func (a *AmazonDownloader) GetAvailableAPIs() []string { // DoubleDouble service regions (same as PC) @@ -176,11 +167,9 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) - // Step 1: Submit download request with rate limiting encodedURL := url.QueryEscape(amazonURL) submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) - // Apply rate limiting before request (like PC version) a.waitForRateLimit() req, err := http.NewRequest("GET", submitURL, nil) @@ -334,7 +323,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) } -// DownloadFile downloads a file from URL with User-Agent and progress tracking func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { ctx := context.Background() @@ -434,7 +422,6 @@ type AmazonDownloadResult struct { ISRC string } -// downloadFromAmazon downloads a track using the request parameters // Uses DoubleDouble service (same as PC version) func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() @@ -580,15 +567,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Handle lyrics based on LyricsMode setting - // Mode: "embed" (default), "external" (.lrc file), "both" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { lyricsMode = "embed" // default } - // Save external .lrc file if mode is "external" or "both" if lyricsMode == "external" || lyricsMode == "both" { GoLog("[Amazon] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { @@ -598,7 +582,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - // Embed lyrics if mode is "embed" or "both" if lyricsMode == "embed" || lyricsMode == "both" { GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { diff --git a/go_backend/cover.go b/go_backend/cover.go index 88d4d29b..fd0ed822 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -8,18 +8,15 @@ import ( "strings" ) -// Spotify image size codes (same as PC version) const ( - spotifySize300 = "ab67616d00001e02" // 300x300 (small) - spotifySize640 = "ab67616d0000b273" // 640x640 (medium) - spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000) + spotifySize300 = "ab67616d00001e02" + spotifySize640 = "ab67616d0000b273" + spotifySizeMax = "ab67616d000082c1" ) // Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800 var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) -// convertSmallToMedium upgrades 300x300 cover URL to 640x640 -// Same logic as PC version for consistency func convertSmallToMedium(imageURL string) string { if strings.Contains(imageURL, spotifySize300) { return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) @@ -27,8 +24,6 @@ func convertSmallToMedium(imageURL string) string { return imageURL } -// downloadCoverToMemory downloads cover art and returns as bytes (no file creation) -// This avoids file permission issues on Android func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { if coverURL == "" { return nil, fmt.Errorf("no cover URL provided") @@ -90,8 +85,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { return data, nil } -// upgradeToMaxQuality upgrades cover URL to maximum quality -// Supports both Spotify and Deezer CDNs func upgradeToMaxQuality(coverURL string) string { // Spotify CDN upgrade if strings.Contains(coverURL, spotifySize640) { @@ -106,9 +99,6 @@ func upgradeToMaxQuality(coverURL string) string { return coverURL } -// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800) -// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg -// Available sizes: 56, 250, 500, 1000, 1400, 1800 func upgradeDeezerCover(coverURL string) string { if !strings.Contains(coverURL, "cdn-images.dzcdn.net") { return coverURL @@ -122,7 +112,6 @@ func upgradeDeezerCover(coverURL string) string { return upgraded } -// GetCoverFromSpotify gets cover URL from Spotify metadata func GetCoverFromSpotify(imageURL string, maxQuality bool) string { if imageURL == "" { return "" diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 9b6ff111..cb82a7f4 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -25,13 +25,12 @@ const ( deezerMaxParallelISRC = 10 ) -// DeezerClient handles Deezer API interactions (no auth required) type DeezerClient struct { httpClient *http.Client searchCache map[string]*cacheEntry albumCache map[string]*cacheEntry artistCache map[string]*cacheEntry - isrcCache map[string]string // trackID -> ISRC cache + isrcCache map[string]string cacheMu sync.RWMutex } @@ -40,7 +39,6 @@ var ( deezerClientOnce sync.Once ) -// GetDeezerClient returns singleton Deezer client func GetDeezerClient() *DeezerClient { deezerClientOnce.Do(func() { deezerClient = &DeezerClient{ @@ -54,7 +52,6 @@ func GetDeezerClient() *DeezerClient { return deezerClient } -// Deezer API response types type deezerTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -63,7 +60,7 @@ type deezerTrack struct { DiskNumber int `json:"disk_number"` ISRC string `json:"isrc"` Link string `json:"link"` - ReleaseDate string `json:"release_date"` // Sometimes at track level + ReleaseDate string `json:"release_date"` Artist deezerArtist `json:"artist"` Album deezerAlbumSimple `json:"album"` Contributors []deezerArtist `json:"contributors"` @@ -86,8 +83,8 @@ type deezerAlbumSimple struct { CoverMedium string `json:"cover_medium"` CoverBig string `json:"cover_big"` CoverXL string `json:"cover_xl"` - ReleaseDate string `json:"release_date"` // Sometimes at album level - RecordType string `json:"record_type"` // album, single, ep, compile + ReleaseDate string `json:"release_date"` + RecordType string `json:"record_type"` } func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { @@ -146,8 +143,8 @@ type deezerAlbumFull struct { CoverXL string `json:"cover_xl"` ReleaseDate string `json:"release_date"` NbTracks int `json:"nb_tracks"` - RecordType string `json:"record_type"` // album, single, ep, compile - Label string `json:"label"` // Record label name + RecordType string `json:"record_type"` + Label string `json:"label"` Genres struct { Data []deezerGenre `json:"data"` } `json:"genres"` @@ -185,7 +182,6 @@ type deezerPlaylistFull struct { } `json:"tracks"` } -// SearchAll searches for tracks and artists on Deezer // NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit) @@ -230,11 +226,9 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data)) for _, track := range trackResp.Data { - // Convert directly without fetching ISRC - much faster result.Tracks = append(result.Tracks, c.convertTrack(track)) } - // Search artists artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) GoLog("[Deezer] Fetching artists from: %s\n", artistURL) @@ -267,7 +261,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists)) - // Cache result c.cacheMu.Lock() c.searchCache[cacheKey] = &cacheEntry{ data: result, @@ -292,7 +285,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp }, nil } -// GetAlbum fetches album with tracks // ISRC is fetched in parallel for better performance func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) { c.cacheMu.RLock() @@ -338,7 +330,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp Label: album.Label, // From Deezer album } - // Fetch ISRCs in parallel isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data) tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) @@ -386,7 +377,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp return result, nil } -// GetArtist fetches artist with albums func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) { c.cacheMu.RLock() if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { @@ -472,8 +462,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR return result, nil } -// GetPlaylist fetches playlist with tracks -// ISRC is fetched in parallel for better performance func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) @@ -496,7 +484,6 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla info.Owner.Name = playlist.Title info.Owner.Images = playlistImage - // Fetch ISRCs in parallel isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data) tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data)) @@ -535,15 +522,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla }, nil } -// SearchByISRC searches for a track by ISRC using direct endpoint func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) { - // Use direct ISRC endpoint (API 2.0) - // https://api.deezer.com/2.0/track/isrc:{ISRC} directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc) var track deezerTrack if err := c.getJSON(ctx, directURL, &track); err != nil { - // Fallback to search if direct endpoint fails searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc) var resp struct { Data []deezerTrack `json:"data"` @@ -623,7 +606,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr go func(t deezerTrack) { defer wg.Done() - // Acquire semaphore select { case sem <- struct{}{}: defer func() { <-sem }() @@ -652,7 +634,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr return result } -// GetTrackISRC fetches ISRC for a single track (with caching) // Use this when you need ISRC for download func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) { c.cacheMu.RLock() @@ -662,13 +643,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string } c.cacheMu.RUnlock() - // Fetch from API fullTrack, err := c.fetchFullTrack(ctx, trackID) if err != nil { return "", err } - // Cache the result c.cacheMu.Lock() c.isrcCache[trackID] = fullTrack.ISRC c.cacheMu.Unlock() @@ -715,20 +694,17 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { return album.Cover } -// AlbumExtendedMetadata contains genre and label information from an album type AlbumExtendedMetadata struct { Genre string // Comma-separated list of genres Label string // Record label name } -// GetAlbumExtendedMetadata fetches genre and label from a Deezer album // Uses the album ID from a track to fetch extended metadata func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { if albumID == "" { return nil, fmt.Errorf("empty album ID") } - // Check cache first cacheKey := fmt.Sprintf("album_meta:%s", albumID) c.cacheMu.RLock() if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { @@ -744,7 +720,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str return nil, fmt.Errorf("failed to fetch album: %w", err) } - // Extract genres as comma-separated string var genres []string for _, g := range album.Genres.Data { if g.Name != "" { @@ -757,7 +732,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str Label: album.Label, } - // Cache the result c.cacheMu.Lock() c.searchCache[cacheKey] = &cacheEntry{ data: result, @@ -782,7 +756,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str return fmt.Sprintf("%d", track.Album.ID), nil } -// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID // This is a convenience function that first gets the album ID, then fetches album metadata func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) { albumID, err := c.GetTrackAlbumID(ctx, trackID) @@ -837,7 +810,6 @@ func parseDeezerURL(input string) (string, string, error) { parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - // Skip language prefix if present (e.g., /en/, /fr/) if len(parts) > 0 && len(parts[0]) == 2 { parts = parts[1:] } diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go index 6d31705e..15e80370 100644 --- a/go_backend/duplicate.go +++ b/go_backend/duplicate.go @@ -158,7 +158,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { return "", false } - // Use index for fast lookup idx := GetISRCIndex(outputDir) filePath, exists := idx.lookup(isrc) if !exists { @@ -175,7 +174,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { } // CheckISRCExists is the exported version for gomobile (returns string, error) -// Returns the filepath if exists, empty string if not func CheckISRCExists(outputDir, isrc string) (string, error) { filepath, _ := checkISRCExistsInternal(outputDir, isrc) return filepath, nil @@ -199,9 +197,6 @@ type FileExistenceResult struct { ArtistName string `json:"artist_name,omitempty"` } -// CheckFilesExistParallel checks if multiple files exist in parallel -// It builds an ISRC index from the output directory once, then checks all tracks against it -// Same implementation as PC version for consistency func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) { var tracks []struct { ISRC string `json:"isrc"` @@ -266,7 +261,6 @@ func PreBuildISRCIndex(outputDir string) error { } // AddToISRCIndex adds a new file to the ISRC index after successful download -// This avoids rebuilding the entire index func AddToISRCIndex(outputDir, isrc, filePath string) { if outputDir == "" || isrc == "" || filePath == "" { return diff --git a/go_backend/exports.go b/go_backend/exports.go index d5268f56..17112d93 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -13,8 +13,6 @@ import ( "github.com/dop251/goja" ) -// ParseSpotifyURL parses and validates a Spotify URL -// Returns JSON with type (track/album/playlist) and ID func ParseSpotifyURL(url string) (string, error) { parsed, err := parseSpotifyURI(url) if err != nil { @@ -34,19 +32,14 @@ func ParseSpotifyURL(url string) (string, error) { return string(jsonBytes), nil } -// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter func SetSpotifyAPICredentials(clientID, clientSecret string) { SetSpotifyCredentials(clientID, clientSecret) } -// CheckSpotifyCredentials checks if Spotify credentials are configured -// Returns true if credentials are available (custom or env vars) func CheckSpotifyCredentials() bool { return HasSpotifyCredentials() } -// GetSpotifyMetadata fetches metadata from Spotify URL -// Returns JSON with track/album/playlist data func GetSpotifyMetadata(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -68,8 +61,6 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) { return string(jsonBytes), nil } -// SearchSpotify searches for tracks on Spotify -// Returns JSON array of track results func SearchSpotify(query string, limit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -91,8 +82,6 @@ func SearchSpotify(query string, limit int) (string, error) { return string(jsonBytes), nil } -// SearchSpotifyAll searches for tracks and artists on Spotify -// Returns JSON with tracks and artists arrays func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -114,8 +103,6 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) return string(jsonBytes), nil } -// CheckAvailability checks track availability on streaming services -// Returns JSON with availability info for Tidal, Qobuz, Amazon func CheckAvailability(spotifyID, isrc string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) @@ -131,7 +118,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) { return string(jsonBytes), nil } -// DownloadRequest represents a download request from Flutter type DownloadRequest struct { ISRC string `json:"isrc"` Service string `json:"service"` @@ -143,58 +129,51 @@ type DownloadRequest struct { CoverURL string `json:"cover_url"` OutputDir string `json:"output_dir"` FilenameFormat string `json:"filename_format"` - Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS + Quality string `json:"quality"` EmbedLyrics bool `json:"embed_lyrics"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` TrackNumber int `json:"track_number"` DiscNumber int `json:"disc_number"` TotalTracks int `json:"total_tracks"` ReleaseDate string `json:"release_date"` - ItemID string `json:"item_id"` // Unique ID for progress tracking - DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) - Source string `json:"source"` // Extension ID that provided this track (prioritize this extension) - // Extended metadata from Deezer for FLAC tagging - Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated - Label string `json:"label,omitempty"` // Record label name - Copyright string `json:"copyright,omitempty"` // Copyright information - // Enriched IDs from Odesli/song.link - used to skip search and directly fetch - TidalID string `json:"tidal_id,omitempty"` - QobuzID string `json:"qobuz_id,omitempty"` - DeezerID string `json:"deezer_id,omitempty"` - // Lyrics mode: "embed" (default), "external" (.lrc file), "both" - LyricsMode string `json:"lyrics_mode,omitempty"` + ItemID string `json:"item_id"` + DurationMS int `json:"duration_ms"` + Source string `json:"source"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + TidalID string `json:"tidal_id,omitempty"` + QobuzID string `json:"qobuz_id,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` + LyricsMode string `json:"lyrics_mode,omitempty"` } // DownloadResponse represents the result of a download type DownloadResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - FilePath string `json:"file_path,omitempty"` - Error string `json:"error,omitempty"` - ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown" - AlreadyExists bool `json:"already_exists,omitempty"` - // Actual quality info from the source - ActualBitDepth int `json:"actual_bit_depth,omitempty"` - ActualSampleRate int `json:"actual_sample_rate,omitempty"` - Service string `json:"service,omitempty"` // Actual service used (for fallback) - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - ISRC string `json:"isrc,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - // Extended metadata for FLAC tagging (passed to Flutter for M4A->FLAC conversion) - Genre string `json:"genre,omitempty"` // Music genre(s) - Label string `json:"label,omitempty"` // Record label - Copyright string `json:"copyright,omitempty"` // Copyright info - // If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata) - SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` + Success bool `json:"success"` + Message string `json:"message"` + FilePath string `json:"file_path,omitempty"` + Error string `json:"error,omitempty"` + ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown" + AlreadyExists bool `json:"already_exists,omitempty"` + ActualBitDepth int `json:"actual_bit_depth,omitempty"` + ActualSampleRate int `json:"actual_sample_rate,omitempty"` + Service string `json:"service,omitempty"` // Actual service used (for fallback) + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ISRC string `json:"isrc,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` } -// DownloadResult is a generic result type for all downloaders type DownloadResult struct { FilePath string BitDepth int @@ -208,9 +187,6 @@ type DownloadResult struct { ISRC string } -// DownloadTrack downloads a track from the specified service -// requestJSON is a JSON string of DownloadRequest -// Returns JSON string of DownloadResponse func DownloadTrack(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { @@ -224,7 +200,6 @@ func DownloadTrack(requestJSON string) (string, error) { req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) - // Add output directory to allowed download dirs for extensions if req.OutputDir != "" { AddAllowedDownloadDir(req.OutputDir) } @@ -348,22 +323,18 @@ func DownloadTrack(requestJSON string) (string, error) { return string(jsonBytes), nil } -// DownloadWithFallback tries to download from services in order -// Starts with the preferred service from request, then tries others func DownloadWithFallback(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } - // Trim whitespace from string fields to prevent filename/path issues req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) - // Add output directory to allowed download dirs for extensions if req.OutputDir != "" { AddAllowedDownloadDir(req.OutputDir) } @@ -520,47 +491,36 @@ func DownloadWithFallback(requestJSON string) (string, error) { return errorResponse("All services failed. Last error: " + lastErr.Error()) } -// GetDownloadProgress returns current download progress func GetDownloadProgress() string { progress := getProgress() jsonBytes, _ := json.Marshal(progress) return string(jsonBytes) } -// GetAllDownloadProgress returns progress for all active downloads (concurrent mode) func GetAllDownloadProgress() string { return GetMultiProgress() } -// InitItemProgress initializes progress tracking for a download item func InitItemProgress(itemID string) { StartItemProgress(itemID) } -// FinishItemProgress marks a download item as complete and removes tracking func FinishItemProgress(itemID string) { CompleteItemProgress(itemID) } -// ClearItemProgress removes progress tracking for a specific item func ClearItemProgress(itemID string) { RemoveItemProgress(itemID) } -// CancelDownload cancels an in-progress download for the given item. func CancelDownload(itemID string) { cancelDownload(itemID) } -// CleanupConnections closes idle HTTP connections -// Call this periodically during large batch downloads to prevent TCP exhaustion func CleanupConnections() { CloseIdleConnections() } -// ReadFileMetadata reads metadata directly from a FLAC file -// Returns JSON with all embedded metadata (title, artist, album, track number, etc.) -// This is useful for displaying accurate metadata in the UI without relying on cached data func ReadFileMetadata(filePath string) (string, error) { metadata, err := ReadMetadata(filePath) if err != nil { @@ -600,12 +560,10 @@ func ReadFileMetadata(filePath string) (string, error) { return string(jsonBytes), nil } -// SetDownloadDirectory sets the default download directory func SetDownloadDirectory(path string) error { return setDownloadDir(path) } -// CheckDuplicate checks if a file with the given ISRC exists func CheckDuplicate(outputDir, isrc string) (string, error) { existingFile, exists := CheckISRCExists(outputDir, isrc) @@ -622,26 +580,18 @@ func CheckDuplicate(outputDir, isrc string) (string, error) { return string(jsonBytes), nil } -// CheckDuplicatesBatch checks multiple files for duplicates in parallel -// Uses ISRC index for fast lookup (builds index once, checks all tracks) -// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...] -// Returns JSON array of results func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) { return CheckFilesExistParallel(outputDir, tracksJSON) } -// PreBuildDuplicateIndex pre-builds the ISRC index for a directory -// Call this when entering album/playlist screen for faster duplicate checking func PreBuildDuplicateIndex(outputDir string) error { return PreBuildISRCIndex(outputDir) } -// InvalidateDuplicateIndex clears the ISRC index cache for a directory func InvalidateDuplicateIndex(outputDir string) { InvalidateISRCCache(outputDir) } -// BuildFilename builds a filename from template and metadata func BuildFilename(template string, metadataJSON string) (string, error) { var metadata map[string]interface{} if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { @@ -652,14 +602,10 @@ func BuildFilename(template string, metadataJSON string) (string, error) { return filename, nil } -// SanitizeFilename removes invalid characters from filename func SanitizeFilename(filename string) string { return sanitizeFilename(filename) } -// FetchLyrics fetches lyrics for a track from LRCLIB -// Returns JSON with lyrics data -// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) { client := NewLyricsClient() durationSec := float64(durationMs) / 1000.0 @@ -683,9 +629,6 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str return string(jsonBytes), nil } -// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers -// First tries to extract from file, then falls back to fetching from internet -// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { if filePath != "" { lyrics, err := ExtractLyrics(filePath) @@ -705,7 +648,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura return lrcContent, nil } -// EmbedLyricsToFile embeds lyrics into an existing FLAC file func EmbedLyricsToFile(filePath, lyrics string) (string, error) { err := EmbedLyrics(filePath, lyrics) if err != nil { @@ -721,9 +663,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) { return string(jsonBytes), nil } -// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks -// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service -// This runs in background and returns immediately func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { var tracks []struct { ISRC string `json:"isrc"` @@ -759,20 +698,14 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { return string(jsonBytes), nil } -// GetTrackCacheSize returns the current track ID cache size func GetTrackCacheSize() int { return GetCacheSize() } -// ClearTrackIDCache clears the track ID cache func ClearTrackIDCache() { ClearTrackCache() } -// ==================== DEEZER API ==================== - -// SearchDeezerAll searches for tracks and artists on Deezer (no API key required) -// Returns JSON with tracks and artists arrays func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -990,10 +923,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API") } -// ==================== SONGLINK DEEZER SUPPORT ==================== - -// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source -// Returns JSON with availability info for Spotify, Tidal, Amazon, etc. func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID) @@ -1177,14 +1106,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) { return "", err } - // Initialize with saved settings settingsStore := GetExtensionSettingsStore() settings := settingsStore.GetAll(ext.ID) if len(settings) > 0 { manager.InitializeExtension(ext.ID, settings) } - // Return extension info as JSON result := map[string]interface{}{ "id": ext.ID, "display_name": ext.Manifest.DisplayName, @@ -1348,8 +1275,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION AUTH API ==================== - // GetExtensionPendingAuthJSON returns pending auth request for an extension func GetExtensionPendingAuthJSON(extensionID string) (string, error) { req := GetPendingAuthRequest(extensionID) @@ -1429,9 +1354,6 @@ func GetAllPendingAuthRequestsJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION FFMPEG API ==================== - -// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute func GetPendingFFmpegCommandJSON(commandID string) (string, error) { cmd := GetPendingFFmpegCommand(commandID) if cmd == nil { @@ -1491,7 +1413,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) if err != nil { - // Extension not found, return original track return trackJSON, nil } @@ -1595,10 +1516,6 @@ func GetSearchProvidersJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION URL HANDLER ==================== - -// HandleURLWithExtensionJSON tries to handle a URL with any matching extension -// Returns JSON with type, tracks, album info, etc. func HandleURLWithExtensionJSON(url string) (string, error) { manager := GetExtensionManager() resultWithID, err := manager.HandleURLWithExtension(url) @@ -1860,7 +1777,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error return "", fmt.Errorf("failed to marshal result: %w", err) } - // Parse into album metadata (same structure) var album ExtAlbumMetadata if err := json.Unmarshal(jsonBytes, &album); err != nil { return "", fmt.Errorf("failed to parse playlist: %w", err) @@ -1961,7 +1877,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { response["header_image"] = artist.HeaderImage } - // Add listeners if present if artist.Listeners > 0 { response["listeners"] = artist.Listeners } @@ -2019,9 +1934,6 @@ func GetURLHandlersJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION POST-PROCESSING ==================== - -// RunPostProcessingJSON runs post-processing hooks on a file func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { var metadata map[string]interface{} if metadataJSON != "" { @@ -2077,8 +1989,6 @@ func GetPostProcessingProvidersJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION STORE ==================== - // InitExtensionStoreJSON initializes the extension store with cache directory func InitExtensionStoreJSON(cacheDir string) error { InitExtensionStore(cacheDir) @@ -2092,7 +2002,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { return "", fmt.Errorf("extension store not initialized") } - // Force refresh if requested if forceRefresh { store.FetchRegistry(true) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 9ab396aa..baa4ba72 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension management functionality package gobackend import ( @@ -15,8 +14,6 @@ import ( "github.com/dop251/goja" ) -// compareVersions compares two semantic version strings -// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 func compareVersions(v1, v2 string) int { parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") @@ -46,7 +43,6 @@ func compareVersions(v1, v2 string) int { return 0 } -// LoadedExtension represents an extension that has been loaded into memory type LoadedExtension struct { ID string `json:"id"` Manifest *ExtensionManifest `json:"manifest"` @@ -72,7 +68,6 @@ var ( globalExtManagerOnce sync.Once ) -// GetExtensionManager returns the global extension manager instance func GetExtensionManager() *ExtensionManager { globalExtManagerOnce.Do(func() { globalExtManager = &ExtensionManager{ @@ -82,7 +77,6 @@ func GetExtensionManager() *ExtensionManager { return globalExtManager } -// SetDirectories sets the extensions and data directories func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { m.mu.Lock() defer m.mu.Unlock() @@ -100,9 +94,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { return nil } -// LoadExtensionFromFile loads an extension from a .spotiflac-ext file func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) { - // Validate file extension if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") } @@ -181,14 +173,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens return nil, fmt.Errorf("failed to create extension directory: %w", err) } - // Extract all files (preserving directory structure) for _, file := range zipReader.File { if file.FileInfo().IsDir() { continue } - // Preserve relative path within the zip (support subdirectories) - // Clean the path to prevent path traversal attacks relPath := filepath.Clean(file.Name) if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) @@ -246,7 +235,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens return ext, nil } -// initializeVM creates and initializes the Goja VM for an extension func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { vm := goja.New() ext.VM = vm @@ -323,7 +311,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error { return nil } -// GetExtension returns a loaded extension by ID // Returns error if extension not found (gomobile compatible) func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) { m.mu.RLock() @@ -348,7 +335,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { return result } -// SetExtensionEnabled enables or disables an extension func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { m.mu.Lock() defer m.mu.Unlock() @@ -409,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string return loaded, errors } -// loadExtensionFromDirectory loads an extension from an already extracted directory func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) { m.mu.Lock() defer m.mu.Unlock() @@ -498,7 +483,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error { return nil } -// UpgradeExtension upgrades an existing extension from a new package file // Only allows upgrades (new version > current version), not downgrades func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { // Validate file extension @@ -645,7 +629,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, return ext, nil } -// ExtensionUpgradeInfo holds information about extension upgrade check type ExtensionUpgradeInfo struct { ExtensionID string `json:"extension_id"` CurrentVersion string `json:"current_version"` @@ -717,7 +700,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte return info, nil } -// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { info, err := m.checkExtensionUpgradeInternal(filePath) if err != nil { @@ -827,7 +809,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { // ==================== Extension Lifecycle ==================== -// InitializeExtension calls the extension's initialize method with settings func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { m.mu.Lock() defer m.mu.Unlock() @@ -889,7 +870,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[ return nil } -// CleanupExtension calls the extension's cleanup method func (m *ExtensionManager) CleanupExtension(extensionID string) error { m.mu.Lock() defer m.mu.Unlock() @@ -900,10 +880,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error { } if ext.VM == nil { - return nil // No VM, nothing to cleanup + return nil } - // Call cleanup function script := ` (function() { if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { @@ -952,16 +931,13 @@ func (m *ExtensionManager) UnloadAllExtensions() { m.mu.Unlock() for _, id := range extensionIDs { - // Call cleanup first m.CleanupExtension(id) - // Then unload m.UnloadExtension(id) } GoLog("[Extension] All extensions unloaded\n") } -// InvokeAction calls a custom action function on an extension (e.g., for button settings) // The function is called as extension.() and can return a result func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) { m.mu.Lock() diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 0a4fce24..7a850a55 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -151,9 +151,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) { return &manifest, nil } -// Validate checks if the manifest has all required fields and valid values func (m *ExtensionManifest) Validate() error { - // Check required fields if strings.TrimSpace(m.Name) == "" { return &ManifestValidationError{Field: "name", Message: "name is required"} } @@ -174,7 +172,6 @@ func (m *ExtensionManifest) Validate() error { return &ManifestValidationError{Field: "type", Message: "at least one type is required"} } - // Validate extension types for _, t := range m.Types { if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider { return &ManifestValidationError{ @@ -200,21 +197,6 @@ func (m *ExtensionManifest) Validate() error { } } - // Validate setting type - validTypes := map[SettingType]bool{ - SettingTypeString: true, - SettingTypeNumber: true, - SettingTypeBool: true, - SettingTypeSelect: true, - SettingTypeButton: true, - } - if !validTypes[setting.Type] { - return &ManifestValidationError{ - Field: fmt.Sprintf("settings[%d].type", i), - Message: fmt.Sprintf("invalid setting type: %s", setting.Type), - } - } - // Select type requires options if setting.Type == SettingTypeSelect && len(setting.Options) == 0 { return &ManifestValidationError{ @@ -223,7 +205,6 @@ func (m *ExtensionManifest) Validate() error { } } - // Button type requires action if setting.Type == SettingTypeButton && setting.Action == "" { return &ManifestValidationError{ Field: fmt.Sprintf("settings[%d].action", i), @@ -300,7 +281,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool { return false } - // Parse URL to get host urlStr = strings.ToLower(strings.TrimSpace(urlStr)) for _, pattern := range m.URLHandler.Patterns { pattern = strings.ToLower(strings.TrimSpace(pattern)) diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 33de3653..c01d2665 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension runtime with sandboxed execution package gobackend import ( @@ -17,7 +16,6 @@ var ( extensionAuthStateMu sync.RWMutex ) -// ExtensionAuthState holds auth state for an extension type ExtensionAuthState struct { PendingAuthURL string AuthCode string @@ -30,7 +28,6 @@ type ExtensionAuthState struct { PKCEChallenge string } -// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL type PendingAuthRequest struct { ExtensionID string AuthURL string @@ -55,7 +52,6 @@ func ClearPendingAuthRequest(extensionID string) { delete(pendingAuthRequests, extensionID) } -// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback) func SetExtensionAuthCode(extensionID string, authCode string) { extensionAuthStateMu.Lock() defer extensionAuthStateMu.Unlock() @@ -68,7 +64,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) { state.AuthCode = authCode } -// SetExtensionTokens sets access/refresh tokens for an extension func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) { extensionAuthStateMu.Lock() defer extensionAuthStateMu.Unlock() @@ -84,7 +79,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex state.IsAuthenticated = accessToken != "" } -// ExtensionRuntime provides sandboxed APIs for extensions type ExtensionRuntime struct { extensionID string manifest *ExtensionManifest @@ -95,7 +89,6 @@ type ExtensionRuntime struct { vm *goja.Runtime } -// NewExtensionRuntime creates a new runtime for an extension func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { jar, _ := newSimpleCookieJar() @@ -108,7 +101,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { vm: ext.VM, } - // Create HTTP client with redirect validation to prevent SSRF via open redirect client := &http.Client{ Timeout: 30 * time.Second, Jar: jar, @@ -119,7 +111,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) return &RedirectBlockedError{Domain: domain} } - // Also block redirects to private/local networks (SSRF protection) if isPrivateIP(domain) { GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) return &RedirectBlockedError{Domain: domain, IsPrivate: true} @@ -136,7 +127,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { return runtime } -// RedirectBlockedError is returned when a redirect is blocked due to domain validation type RedirectBlockedError struct { Domain string IsPrivate bool @@ -162,10 +152,10 @@ func isPrivateIP(host string) bool { "172.24.", "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.", "192.168.", - "169.254.", // Link-local - "::1", // IPv6 localhost - "fc00:", // IPv6 private - "fe80:", // IPv6 link-local + "169.254.", + "::1", + "fc00:", + "fe80:", } hostLower := host @@ -183,7 +173,6 @@ func isPrivateIP(host string) bool { return false } -// simpleCookieJar is a simple in-memory cookie jar type simpleCookieJar struct { cookies map[string][]*http.Cookie mu sync.RWMutex @@ -208,7 +197,6 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie { return j.cookies[u.Host] } -// SetSettings updates the runtime settings func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { r.settings = settings } @@ -228,7 +216,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { httpObj.Set("clearCookies", r.httpClearCookies) vm.Set("http", httpObj) - // Storage API storageObj := vm.NewObject() storageObj.Set("get", r.storageGet) storageObj.Set("set", r.storageSet) @@ -243,7 +230,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { credentialsObj.Set("has", r.credentialsHas) vm.Set("credentials", credentialsObj) - // Auth API (for OAuth and other auth flows) authObj := vm.NewObject() authObj.Set("openAuthUrl", r.authOpenUrl) authObj.Set("getAuthCode", r.authGetCode) @@ -270,7 +256,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { fileObj.Set("getSize", r.fileGetSize) vm.Set("file", fileObj) - // FFmpeg API (for post-processing) ffmpegObj := vm.NewObject() ffmpegObj.Set("execute", r.ffmpegExecute) ffmpegObj.Set("getInfo", r.ffmpegGetInfo) @@ -284,7 +269,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { matchingObj.Set("normalizeString", r.matchingNormalizeString) vm.Set("matching", matchingObj) - // Utilities utilsObj := vm.NewObject() utilsObj.Set("base64Encode", r.base64Encode) utilsObj.Set("base64Decode", r.base64Decode) @@ -310,7 +294,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { logObj.Set("error", r.logError) vm.Set("log", logObj) - // Go backend functions gobackendObj := vm.NewObject() gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) vm.Set("gobackend", gobackendObj) @@ -321,16 +304,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { // Global fetch() - Promise-style HTTP API (browser-compatible) vm.Set("fetch", r.fetchPolyfill) - // Global atob/btoa - Base64 encoding (browser-compatible) vm.Set("atob", r.atobPolyfill) vm.Set("btoa", r.btoaPolyfill) - // TextEncoder/TextDecoder constructors r.registerTextEncoderDecoder(vm) - // URL class for URL parsing r.registerURLClass(vm) - // JSON global (browser-compatible) r.registerJSONGlobal(vm) } diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go index 4e5102ef..ce63b1d8 100644 --- a/go_backend/extension_runtime_auth.go +++ b/go_backend/extension_runtime_auth.go @@ -18,7 +18,6 @@ import ( // ==================== Auth API (OAuth Support) ==================== -// authOpenUrl requests Flutter to open an OAuth URL func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { callbackURL = call.Arguments[1].String() } - // Store pending auth request for Flutter to pick up pendingAuthRequestsMu.Lock() pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ ExtensionID: r.extensionID, @@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { } pendingAuthRequestsMu.Unlock() - // Update auth state extensionAuthStateMu.Lock() state, exists := extensionAuthState[r.extensionID] if !exists { @@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { extensionAuthState[r.extensionID] = state } state.PendingAuthURL = authURL - state.AuthCode = "" // Clear any previous auth code + state.AuthCode = "" extensionAuthStateMu.Unlock() GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL) @@ -61,7 +58,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { }) } -// authGetCode gets the auth code (set by Flutter after OAuth callback) func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { extensionAuthStateMu.RLock() defer extensionAuthStateMu.RUnlock() @@ -114,7 +110,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { return r.vm.ToValue(true) } -// authClear clears all auth state for the extension func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { extensionAuthStateMu.Lock() delete(extensionAuthState, r.extensionID) @@ -138,7 +133,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu return r.vm.ToValue(false) } - // Check if token is expired if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { return r.vm.ToValue(false) } @@ -146,7 +140,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu return r.vm.ToValue(state.IsAuthenticated) } -// authGetTokens returns current tokens (for extension to use in API calls) func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { extensionAuthStateMu.RLock() defer extensionAuthStateMu.RUnlock() @@ -182,16 +175,13 @@ func generatePKCEVerifier(length int) (string, error) { length = 128 } - // Generate random bytes bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { return "", err } - // Use base64url encoding without padding (RFC 7636 compliant) verifier := base64.RawURLEncoding.EncodeToString(bytes) - // Trim to exact length if len(verifier) > length { verifier = verifier[:length] } @@ -199,15 +189,12 @@ func generatePKCEVerifier(length int) (string, error) { return verifier, nil } -// generatePKCEChallenge generates a code challenge from verifier using S256 method func generatePKCEChallenge(verifier string) string { hash := sha256.Sum256([]byte(verifier)) // Base64url encode without padding (RFC 7636) return base64.RawURLEncoding.EncodeToString(hash[:]) } -// authGeneratePKCE generates a PKCE code verifier and challenge pair -// Returns: { verifier: string, challenge: string, method: "S256" } func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { // Default length is 64 characters length := 64 @@ -227,7 +214,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { challenge := generatePKCEChallenge(verifier) - // Store in auth state for later use extensionAuthStateMu.Lock() state, exists := extensionAuthState[r.extensionID] if !exists { @@ -247,7 +233,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { }) } -// authGetPKCE returns the current PKCE verifier and challenge (if generated) func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value { extensionAuthStateMu.RLock() defer extensionAuthStateMu.RUnlock() @@ -405,7 +390,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Get stored PKCE verifier extensionAuthStateMu.RLock() state, exists := extensionAuthState[r.extensionID] var verifier string @@ -421,7 +405,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Validate domain if err := r.validateDomain(tokenURL); err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -429,7 +412,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Build token request body formData := url.Values{} formData.Set("grant_type", "authorization_code") formData.Set("client_id", clientID) @@ -439,14 +421,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja formData.Set("redirect_uri", redirectURI) } - // Add extra params if extraParams, ok := config["extraParams"].(map[string]interface{}); ok { for k, v := range extraParams { formData.Set(k, fmt.Sprintf("%v", v)) } } - // Make token request req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode())) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -475,7 +455,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Parse response var tokenResp map[string]interface{} if err := json.Unmarshal(body, &tokenResp); err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -485,7 +464,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Check for error in response if errMsg, ok := tokenResp["error"].(string); ok { errDesc, _ := tokenResp["error_description"].(string) return r.vm.ToValue(map[string]interface{}{ @@ -495,7 +473,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Extract tokens accessToken, _ := tokenResp["access_token"].(string) refreshToken, _ := tokenResp["refresh_token"].(string) expiresIn, _ := tokenResp["expires_in"].(float64) @@ -508,7 +485,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Store tokens in auth state extensionAuthStateMu.Lock() state, exists = extensionAuthState[r.extensionID] if !exists { @@ -521,14 +497,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja if expiresIn > 0 { state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) } - // Clear PKCE after successful exchange state.PKCEVerifier = "" state.PKCEChallenge = "" extensionAuthStateMu.Unlock() GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID) - // Return full token response result := map[string]interface{}{ "success": true, "access_token": accessToken, @@ -538,7 +512,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja if expiresIn > 0 { result["expires_in"] = expiresIn } - // Include any additional fields from response if scope, ok := tokenResp["scope"].(string); ok { result["scope"] = scope } diff --git a/go_backend/extension_runtime_ffmpeg.go b/go_backend/extension_runtime_ffmpeg.go index 889456bb..f5a5b578 100644 --- a/go_backend/extension_runtime_ffmpeg.go +++ b/go_backend/extension_runtime_ffmpeg.go @@ -31,14 +31,12 @@ var ( ffmpegCommandID int64 ) -// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter) func GetPendingFFmpegCommand(commandID string) *FFmpegCommand { ffmpegCommandsMu.RLock() defer ffmpegCommandsMu.RUnlock() return ffmpegCommands[commandID] } -// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter) func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) { ffmpegCommandsMu.Lock() defer ffmpegCommandsMu.Unlock() @@ -50,14 +48,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str } } -// ClearFFmpegCommand removes a completed FFmpeg command func ClearFFmpegCommand(commandID string) { ffmpegCommandsMu.Lock() defer ffmpegCommandsMu.Unlock() delete(ffmpegCommands, commandID) } -// ffmpegExecute queues an FFmpeg command for execution by Flutter func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -118,7 +114,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { } } -// ffmpegGetInfo gets audio file information using FFprobe func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -147,7 +142,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { }) } -// ffmpegConvert is a helper for common conversion operations func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 82ccec3b..20b720df 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -21,8 +21,6 @@ var ( allowedDownloadDirsMu sync.RWMutex ) -// SetAllowedDownloadDirs sets the list of directories where extensions can write files -// This should be called by the Go backend when setting up download paths func SetAllowedDownloadDirs(dirs []string) { allowedDownloadDirsMu.Lock() defer allowedDownloadDirsMu.Unlock() @@ -30,7 +28,6 @@ func SetAllowedDownloadDirs(dirs []string) { GoLog("[Extension] Allowed download directories set: %v\n", dirs) } -// AddAllowedDownloadDir adds a directory to the allowed list func AddAllowedDownloadDir(dir string) { allowedDownloadDirsMu.Lock() defer allowedDownloadDirsMu.Unlock() @@ -40,7 +37,6 @@ func AddAllowedDownloadDir(dir string) { } } -// isPathInAllowedDirs checks if an absolute path is within any allowed directory func isPathInAllowedDirs(absPath string) bool { allowedDownloadDirsMu.RLock() defer allowedDownloadDirsMu.RUnlock() @@ -62,36 +58,28 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) { return "", fmt.Errorf("file access denied: extension does not have 'file' permission") } - // Clean and resolve the path cleanPath := filepath.Clean(path) - // SECURITY: Block absolute paths by default - // Only allow if path is in explicitly allowed download directories if filepath.IsAbs(cleanPath) { absPath, err := filepath.Abs(cleanPath) if err != nil { return "", fmt.Errorf("invalid path: %w", err) } - // Check if path is in allowed download directories if isPathInAllowedDirs(absPath) { return absPath, nil } - // Block all other absolute paths return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox") } - // For relative paths, join with data directory (extension's sandbox) fullPath := filepath.Join(r.dataDir, cleanPath) - // Resolve to absolute path absPath, err := filepath.Abs(fullPath) if err != nil { return "", fmt.Errorf("invalid path: %w", err) } - // Ensure path is within data directory (prevent path traversal) absDataDir, _ := filepath.Abs(r.dataDir) if !strings.HasPrefix(absPath, absDataDir) { return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) @@ -100,8 +88,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) { return absPath, nil } -// fileDownload downloads a file from URL to the specified path -// Supports progress callback via options.onProgress func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ @@ -113,7 +99,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { urlStr := call.Arguments[0].String() outputPath := call.Arguments[1].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -121,7 +106,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Validate output path (allows absolute paths for download queue) fullPath, err := r.validatePath(outputPath) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -130,20 +114,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Get options if provided var onProgress goja.Callable var headers map[string]string if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { optionsObj := call.Arguments[2].Export() if opts, ok := optionsObj.(map[string]interface{}); ok { - // Extract headers if h, ok := opts["headers"].(map[string]interface{}); ok { headers = make(map[string]string) for k, v := range h { headers[k] = fmt.Sprintf("%v", v) } } - // Extract onProgress callback if progressVal, ok := opts["onProgress"]; ok { if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok { onProgress = callable @@ -152,7 +133,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } } - // Create directory if needed dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0755); err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -161,7 +141,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Create HTTP request req, err := http.NewRequest("GET", urlStr, nil) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -170,7 +149,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Set headers for k, v := range headers { req.Header.Set(k, v) } @@ -178,7 +156,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") } - // Download file resp, err := r.httpClient.Do(req) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -195,7 +172,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Create output file out, err := os.Create(fullPath) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -205,12 +181,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } defer out.Close() - // Get content length for progress contentLength := resp.ContentLength - // Copy content with progress reporting var written int64 - buf := make([]byte, 32*1024) // 32KB buffer + buf := make([]byte, 32*1024) for { nr, er := resp.Body.Read(buf) if nr > 0 { @@ -235,7 +209,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Report progress if onProgress != nil && contentLength > 0 { _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) } @@ -260,7 +233,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } -// fileExists checks if a file exists in the sandbox func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(false) @@ -276,7 +248,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { return r.vm.ToValue(err == nil) } -// fileDelete deletes a file in the sandbox func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -306,7 +277,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { }) } -// fileRead reads a file from the sandbox func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -338,7 +308,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { }) } -// fileWrite writes data to a file in the sandbox func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ @@ -380,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { }) } -// fileCopy copies a file within the sandbox func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ @@ -408,7 +376,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - // Read source file data, err := os.ReadFile(fullSrc) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -417,7 +384,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - // Create destination directory if needed dir := filepath.Dir(fullDst) if err := os.MkdirAll(dir, 0755); err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -426,7 +392,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - // Write to destination if err := os.WriteFile(fullDst, data, 0644); err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -440,7 +405,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } -// fileMove moves/renames a file within the sandbox func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ @@ -468,7 +432,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { }) } - // Create destination directory if needed dir := filepath.Dir(fullDst) if err := os.MkdirAll(dir, 0755); err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -490,7 +453,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { }) } -// fileGetSize returns the size of a file in bytes func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index 61c7b36c..a87365c8 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -52,7 +52,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { urlStr := call.Arguments[0].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ @@ -60,7 +59,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { }) } - // Get headers if provided headers := make(map[string]string) if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { headersObj := call.Arguments[1].Export() @@ -71,7 +69,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { } } - // Create request req, err := http.NewRequest("GET", urlStr, nil) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -97,7 +94,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { } defer resp.Body.Close() - // Read body body, err := io.ReadAll(resp.Body) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -134,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { urlStr := call.Arguments[0].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ @@ -175,7 +170,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { } } - // Create request req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr)) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -204,7 +198,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { } defer resp.Body.Close() - // Read body body, err := io.ReadAll(resp.Body) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -231,8 +224,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { }) } -// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.) -// Usage: http.request(url, options) where options = { method, body, headers } func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -242,7 +233,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { urlStr := call.Arguments[0].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ @@ -326,7 +316,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { } defer resp.Body.Close() - // Read body body, err := io.ReadAll(resp.Body) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -354,7 +343,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { }) } -// httpPut performs a PUT request (shortcut for http.request with method: "PUT") func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { return r.httpMethodShortcut("PUT", call) } @@ -364,7 +352,6 @@ func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { return r.httpMethodShortcut("DELETE", call) } -// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH") func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { return r.httpMethodShortcut("PATCH", call) } @@ -380,7 +367,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC urlStr := call.Arguments[0].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ @@ -465,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC } defer resp.Body.Close() - // Read body body, err := io.ReadAll(resp.Body) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -492,7 +477,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC }) } -// httpClearCookies clears all cookies for this extension func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { if jar, ok := r.cookieJar.(*simpleCookieJar); ok { jar.mu.Lock() diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go index a44bfd33..73108e20 100644 --- a/go_backend/extension_runtime_storage.go +++ b/go_backend/extension_runtime_storage.go @@ -143,19 +143,16 @@ func (r *ExtensionRuntime) getSaltPath() string { func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { saltPath := r.getSaltPath() - // Try to read existing salt salt, err := os.ReadFile(saltPath) if err == nil && len(salt) == 32 { return salt, nil } - // Generate new random salt (32 bytes) salt = make([]byte, 32) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return nil, fmt.Errorf("failed to generate salt: %w", err) } - // Save salt to file if err := os.WriteFile(saltPath, salt, 0600); err != nil { return nil, fmt.Errorf("failed to save salt: %w", err) } @@ -214,7 +211,6 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { return err } - // Encrypt the data key, err := r.getEncryptionKey() if err != nil { return fmt.Errorf("failed to get encryption key: %w", err) diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index abed98b4..37d86920 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -94,7 +94,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { return r.vm.ToValue([]byte{}) } - // Get key - can be string or array of bytes var keyBytes []byte keyArg := call.Arguments[0].Export() switch k := keyArg.(type) { @@ -113,7 +112,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { return r.vm.ToValue([]byte{}) } - // Get message - can be string or array of bytes var msgBytes []byte msgArg := call.Arguments[1].Export() switch m := msgArg.(type) { @@ -136,7 +134,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { mac.Write(msgBytes) result := mac.Sum(nil) - // Convert to array of numbers for JavaScript jsArray := make([]interface{}, len(result)) for i, b := range result { jsArray[i] = int(b) diff --git a/go_backend/extension_settings.go b/go_backend/extension_settings.go index 6f46773c..f76514b3 100644 --- a/go_backend/extension_settings.go +++ b/go_backend/extension_settings.go @@ -42,7 +42,6 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error { return fmt.Errorf("failed to create settings directory: %w", err) } - // Load all existing settings return s.loadAllSettings() } @@ -99,7 +98,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error { settingsPath := s.getSettingsPath(extensionID) - // Create directory if needed dir := filepath.Dir(settingsPath) if err := os.MkdirAll(dir, 0755); err != nil { return err @@ -160,7 +158,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) s.settings[extensionID][key] = value - // Persist to disk return s.saveSettings(extensionID, s.settings[extensionID]) } @@ -198,7 +195,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error { delete(s.settings, extensionID) - // Remove settings file settingsPath := s.getSettingsPath(extensionID) if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) { return err diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 2a2e1097..370046d7 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -35,7 +35,6 @@ type StoreExtension struct { Downloads int `json:"downloads"` UpdatedAt string `json:"updated_at"` MinAppVersion string `json:"min_app_version,omitempty"` - // Alternative camelCase fields (for flexibility) DisplayNameAlt string `json:"displayName,omitempty"` DownloadURLAlt string `json:"downloadUrl,omitempty"` IconURLAlt string `json:"iconUrl,omitempty"` @@ -332,7 +331,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) return fmt.Errorf("download returned HTTP %d", resp.StatusCode) } - // Create destination file out, err := os.Create(destPath) if err != nil { return fmt.Errorf("failed to create file: %w", err) diff --git a/go_backend/filename.go b/go_backend/filename.go index 2be92b20..94a17cf8 100644 --- a/go_backend/filename.go +++ b/go_backend/filename.go @@ -6,10 +6,8 @@ import ( "strings" ) -// Invalid filename characters for Android/Windows/Linux var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) -// sanitizeFilename removes invalid characters from filename func sanitizeFilename(filename string) string { sanitized := invalidChars.ReplaceAllString(filename, "_") @@ -30,7 +28,6 @@ func sanitizeFilename(filename string) string { return sanitized } -// buildFilenameFromTemplate builds a filename from template and metadata func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string { if template == "" { template = "{artist} - {title}" @@ -91,7 +88,6 @@ func formatDiscNumber(n int) string { return fmt.Sprintf("%d", n) } -// extractYear extracts year from date string (YYYY-MM-DD or YYYY) func extractYear(date string) string { if len(date) >= 4 { return date[:4] diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 3a9e2b80..fcd0377d 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -15,8 +15,6 @@ import ( "time" ) -// HTTP utility functions for consistent request handling across all downloaders - // getRandomUserAgent generates a random Windows Chrome User-Agent string // Uses modern Chrome format with build and patch numbers // Windows 11 still reports as "Windows NT 10.0" for compatibility @@ -34,41 +32,6 @@ func getRandomUserAgent() string { ) } -// getRandomMacUserAgent generates a random Mac Chrome User-Agent string -// Alternative format matching referensi/backend/spotify_metadata.go exactly -// func getRandomMacUserAgent() string { -// macMajor := rand.Intn(4) + 11 // macOS 11-14 -// macMinor := rand.Intn(5) + 4 // Minor 4-8 -// webkitMajor := rand.Intn(7) + 530 -// webkitMinor := rand.Intn(7) + 30 -// chromeMajor := rand.Intn(25) + 80 -// chromeBuild := rand.Intn(1500) + 3000 -// chromePatch := rand.Intn(65) + 60 -// safariMajor := rand.Intn(7) + 530 -// safariMinor := rand.Intn(6) + 30 -// -// return fmt.Sprintf( -// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", -// macMajor, -// macMinor, -// webkitMajor, -// webkitMinor, -// chromeMajor, -// chromeBuild, -// chromePatch, -// safariMajor, -// safariMinor, -// ) -// } - -// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent -// func getRandomDesktopUserAgent() string { -// if rand.Intn(2) == 0 { -// return getRandomUserAgent() // Windows -// } -// return getRandomMacUserAgent() // Mac -// } - const ( DefaultTimeout = 60 * time.Second DownloadTimeout = 120 * time.Second @@ -106,7 +69,6 @@ var downloadClient = &http.Client{ Timeout: DownloadTimeout, } -// NewHTTPClientWithTimeout creates an HTTP client with specified timeout func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { return &http.Client{ Transport: sharedTransport, @@ -127,7 +89,6 @@ func CloseIdleConnections() { sharedTransport.CloseIdleConnections() } -// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header // Also checks for ISP blocking on errors func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) @@ -146,7 +107,6 @@ type RetryConfig struct { BackoffFactor float64 } -// DefaultRetryConfig returns default retry configuration func DefaultRetryConfig() RetryConfig { return RetryConfig{ MaxRetries: DefaultMaxRetries, @@ -252,13 +212,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr) } -// calculateNextDelay calculates the next delay with exponential backoff func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration { nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor) return min(nextDelay, config.MaxDelay) } -// getRetryAfterDuration parses Retry-After header and returns duration // Returns 60 seconds as default if header is missing or invalid func getRetryAfterDuration(resp *http.Response) time.Duration { retryAfter := resp.Header.Get("Retry-After") @@ -301,7 +259,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) { return body, nil } -// ValidateResponse checks if response is valid (non-nil, status 2xx) func ValidateResponse(resp *http.Response) error { if resp == nil { return fmt.Errorf("response is nil") @@ -330,7 +287,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st return msg } -// ISPBlockingError represents an error caused by ISP blocking type ISPBlockingError struct { Domain string Reason string @@ -446,7 +402,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError { return nil } -// CheckAndLogISPBlocking checks for ISP blocking and logs if detected // Returns true if ISP blocking was detected func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { ispErr := IsISPBlocking(err, requestURL) @@ -484,7 +439,6 @@ func extractDomain(rawURL string) string { return "unknown" } -// WrapErrorWithISPCheck wraps an error with ISP blocking detection // If ISP blocking is detected, returns a more descriptive error func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { if err == nil { diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index 5c08b03c..7537a782 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -8,7 +8,6 @@ import ( "time" ) -// LogEntry represents a single log entry type LogEntry struct { Timestamp string `json:"timestamp"` Level string `json:"level"` @@ -16,7 +15,6 @@ type LogEntry struct { Message string `json:"message"` } -// LogBuffer stores logs in a circular buffer for retrieval by Flutter type LogBuffer struct { entries []LogEntry maxSize int @@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer { return globalLogBuffer } -// SetLoggingEnabled enables or disables logging func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { lb.mu.Lock() defer lb.mu.Unlock() @@ -55,7 +52,6 @@ func (lb *LogBuffer) IsLoggingEnabled() bool { return lb.loggingEnabled } -// Add adds a log entry to the buffer func (lb *LogBuffer) Add(level, tag, message string) { lb.mu.Lock() defer lb.mu.Unlock() diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 46d959d7..895da25a 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -15,13 +15,9 @@ import ( "time" ) -// ======================================== -// Lyrics Cache with TTL -// ======================================== - const ( - lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours - durationToleranceSec = 10.0 // Duration matching tolerance in seconds + lyricsCacheTTL = 24 * time.Hour + durationToleranceSec = 10.0 ) type lyricsCacheEntry struct { @@ -39,10 +35,8 @@ var globalLyricsCache = &lyricsCache{ } func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string { - // Normalize key: lowercase, trim spaces normalizedArtist := strings.ToLower(strings.TrimSpace(artist)) normalizedTrack := strings.ToLower(strings.TrimSpace(track)) - // Round duration to nearest 10 seconds for cache key roundedDuration := math.Round(durationSec/10) * 10 return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration) } @@ -57,7 +51,6 @@ func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsRes return nil, false } - // Check if expired if time.Now().After(entry.expiresAt) { return nil, false } @@ -76,7 +69,6 @@ func (c *lyricsCache) Set(artist, track string, durationSec float64, response *L } } -// CleanExpired removes expired entries from cache func (c *lyricsCache) CleanExpired() int { c.mu.Lock() defer c.mu.Unlock() @@ -92,7 +84,6 @@ func (c *lyricsCache) CleanExpired() int { return cleaned } -// Size returns current cache size func (c *lyricsCache) Size() int { c.mu.RLock() defer c.mu.RUnlock() @@ -174,8 +165,6 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes return c.parseLRCLibResponse(&lrcResp), nil } -// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching -// durationSec: track duration in seconds, use 0 to skip duration matching func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) { baseURL := "https://lrclib.net/api/search" params := url.Values{} @@ -208,13 +197,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo return nil, fmt.Errorf("no lyrics found") } - // Filter and score results based on duration matching and synced lyrics bestMatch := c.findBestMatch(results, durationSec) if bestMatch != nil { return c.parseLRCLibResponse(bestMatch), nil } - // Fallback: return first result with synced lyrics for _, result := range results { if result.SyncedLyrics != "" { return c.parseLRCLibResponse(&result), nil @@ -224,7 +211,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo return c.parseLRCLibResponse(&results[0]), nil } -// findBestMatch finds the best matching lyrics based on duration and sync status func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { var bestSynced *LRCLibResponse var bestPlain *LRCLibResponse @@ -232,11 +218,9 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec for i := range results { result := &results[i] - // Check duration match if target duration is provided durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec) if durationMatches { - // Prefer synced lyrics over plain if result.SyncedLyrics != "" && bestSynced == nil { bestSynced = result } else if result.PlainLyrics != "" && bestPlain == nil { @@ -245,20 +229,17 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec } } - // Return synced first, then plain if bestSynced != nil { return bestSynced } return bestPlain } -// durationMatches checks if two durations are within tolerance func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool { diff := math.Abs(lrcDuration - targetDuration) return diff <= durationToleranceSec } -// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching // durationSec: track duration in seconds for matching, use 0 to skip duration matching func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) { // Check cache first @@ -396,7 +377,6 @@ func msToLRCTimestamp(ms int64) string { return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) } -// convertToLRC converts lyrics to LRC format string (without metadata headers) // Use convertToLRCWithMetadata for full LRC with headers // Kept for potential future use // func convertToLRC(lyrics *LyricsResponse) string { @@ -423,8 +403,6 @@ func msToLRCTimestamp(ms int64) string { // return builder.String() // } -// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers -// Includes [ti:], [ar:], [by:] headers func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { if lyrics == nil || len(lyrics.Lines) == 0 { return "" @@ -432,13 +410,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri var builder strings.Builder - // Add metadata headers builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) builder.WriteString("[by:SpotiFLAC-Mobile]\n") builder.WriteString("\n") - // Add lyrics lines if lyrics.SyncType == "LINE_SYNCED" { for _, line := range lyrics.Lines { if line.Words == "" { @@ -488,24 +464,17 @@ func simplifyTrackName(name string) string { return strings.TrimSpace(result) } -// SaveLRCFile saves lyrics as a .lrc file next to the audio file -// audioFilePath: path to the audio file (e.g., /path/to/song.flac) -// lrcContent: the LRC format lyrics content -// Returns the path to the saved .lrc file, or error func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { if lrcContent == "" { return "", fmt.Errorf("empty LRC content") } - // Get the directory and base name without extension dir := filepath.Dir(audioFilePath) ext := filepath.Ext(audioFilePath) baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext) - // Create the .lrc file path lrcFilePath := filepath.Join(dir, baseName+".lrc") - // Write the LRC content to the file if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil { return "", fmt.Errorf("failed to write LRC file: %w", err) } diff --git a/go_backend/metadata.go b/go_backend/metadata.go index e6f96fdc..a30fc684 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -11,7 +11,6 @@ import ( "github.com/go-flac/go-flac" ) -// Metadata represents track metadata for embedding type Metadata struct { Title string Artist string @@ -24,12 +23,11 @@ type Metadata struct { ISRC string Description string Lyrics string - Genre string // Music genre (e.g., "Rock", "Pop", "Electronic") - Label string // Record label (ORGANIZATION tag in Vorbis) - Copyright string // Copyright information + Genre string + Label string + Copyright string } -// EmbedMetadata embeds metadata into a FLAC file func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { f, err := flac.ParseFile(filePath) if err != nil { @@ -138,8 +136,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { return f.Save(filePath) } -// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes -// This avoids file permission issues on Android by not requiring a temp file func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error { f, err := flac.ParseFile(filePath) if err != nil { @@ -337,7 +333,6 @@ func fileExists(path string) bool { return err == nil } -// EmbedLyrics embeds lyrics into a FLAC file as a separate operation func EmbedLyrics(filePath string, lyrics string) error { f, err := flac.ParseFile(filePath) if err != nil { @@ -375,11 +370,9 @@ func EmbedLyrics(filePath string, lyrics string) error { return f.Save(filePath) } -// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation -// This is used for extension downloads where the file is already downloaded func EmbedGenreLabel(filePath string, genre, label string) error { if genre == "" && label == "" { - return nil // Nothing to embed + return nil } f, err := flac.ParseFile(filePath) @@ -451,16 +444,12 @@ func ExtractLyrics(filePath string) (string, error) { return "", fmt.Errorf("no lyrics found in file") } -// AudioQuality represents audio quality info from a FLAC file type AudioQuality struct { BitDepth int `json:"bit_depth"` SampleRate int `json:"sample_rate"` TotalSamples int64 `json:"total_samples"` } -// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block -// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker -// For M4A files, it delegates to GetM4AQuality func GetAudioQuality(filePath string) (AudioQuality, error) { file, err := os.Open(filePath) if err != nil { @@ -597,7 +586,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro return nil } -// findAtom finds an atom by name starting from offset func findAtom(data []byte, name string, offset int) int { for i := offset; i < len(data)-8; { size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3])) @@ -689,7 +677,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte { return metaAtom } -// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.) func buildTextAtom(name, value string) []byte { valueBytes := []byte(value) @@ -741,7 +728,6 @@ func buildTrackNumberAtom(track, total int) []byte { return atom } -// buildDiscNumberAtom builds disk atom func buildDiscNumberAtom(disc, total int) []byte { dataAtom := []byte{ 0, 0, 0, 22, // size @@ -767,9 +753,9 @@ func buildDiscNumberAtom(disc, total int) []byte { // buildCoverAtom builds covr atom with image data func buildCoverAtom(coverData []byte) []byte { - imageType := byte(13) // default JPEG + imageType := byte(13) if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' { - imageType = 14 // PNG + imageType = 14 } dataSize := 16 + len(coverData) @@ -779,8 +765,8 @@ func buildCoverAtom(coverData []byte) []byte { dataAtom[2] = byte(dataSize >> 8) dataAtom[3] = byte(dataSize) dataAtom = append(dataAtom, []byte("data")...) - dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG - dataAtom = append(dataAtom, 0, 0, 0, 0) // locale + dataAtom = append(dataAtom, 0, 0, 0, imageType) + dataAtom = append(dataAtom, 0, 0, 0, 0) dataAtom = append(dataAtom, coverData...) atomSize := 8 + len(dataAtom) @@ -795,7 +781,6 @@ func buildCoverAtom(coverData []byte) []byte { return atom } -// GetM4AQuality reads audio quality from M4A file func GetM4AQuality(filePath string) (AudioQuality, error) { data, err := os.ReadFile(filePath) if err != nil { diff --git a/go_backend/parallel.go b/go_backend/parallel.go index 88eee90a..2481ecfe 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -6,11 +6,6 @@ import ( "time" ) -// ======================================== -// ISRC to Track ID Cache -// ======================================== - -// TrackIDCacheEntry holds cached track ID with metadata type TrackIDCacheEntry struct { TidalTrackID int64 QobuzTrackID int64 @@ -18,7 +13,6 @@ type TrackIDCacheEntry struct { ExpiresAt time.Time } -// TrackIDCache caches ISRC to track ID mappings type TrackIDCache struct { cache map[string]*TrackIDCacheEntry mu sync.RWMutex @@ -30,7 +24,6 @@ var ( trackIDCacheOnce sync.Once ) -// GetTrackIDCache returns the global track ID cache func GetTrackIDCache() *TrackIDCache { trackIDCacheOnce.Do(func() { globalTrackIDCache = &TrackIDCache{ @@ -41,7 +34,6 @@ func GetTrackIDCache() *TrackIDCache { return globalTrackIDCache } -// Get retrieves a cached entry by ISRC func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { c.mu.RLock() defer c.mu.RUnlock() @@ -53,7 +45,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { return entry } -// SetTidal caches Tidal track ID for an ISRC func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { c.mu.Lock() defer c.mu.Unlock() @@ -67,7 +58,6 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { entry.ExpiresAt = time.Now().Add(c.ttl) } -// SetQobuz caches Qobuz track ID for an ISRC func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { c.mu.Lock() defer c.mu.Unlock() @@ -81,7 +71,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { entry.ExpiresAt = time.Now().Add(c.ttl) } -// SetAmazon caches Amazon track ID for an ISRC func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { c.mu.Lock() defer c.mu.Unlock() @@ -95,24 +84,18 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { entry.ExpiresAt = time.Now().Add(c.ttl) } -// Clear removes all cached entries func (c *TrackIDCache) Clear() { c.mu.Lock() defer c.mu.Unlock() c.cache = make(map[string]*TrackIDCacheEntry) } -// Size returns the number of cached entries func (c *TrackIDCache) Size() int { c.mu.RLock() defer c.mu.RUnlock() return len(c.cache) } -// ======================================== -// Parallel Download Helper -// ======================================== - // ParallelDownloadResult holds results from parallel operations type ParallelDownloadResult struct { CoverData []byte @@ -122,9 +105,6 @@ type ParallelDownloadResult struct { LyricsErr error } -// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel -// This runs while the main audio download is happening -// durationMs: track duration in milliseconds for lyrics matching func FetchCoverAndLyricsParallel( coverURL string, maxQualityCover bool, @@ -153,7 +133,6 @@ func FetchCoverAndLyricsParallel( }() } - // Fetch lyrics in parallel if embedLyrics { wg.Add(1) go func() { @@ -180,11 +159,6 @@ func FetchCoverAndLyricsParallel( return result } -// ======================================== -// Pre-warm Cache for Album/Playlist -// ======================================== - -// PreWarmCacheRequest represents a track to pre-warm cache for type PreWarmCacheRequest struct { ISRC string TrackName string @@ -193,8 +167,6 @@ type PreWarmCacheRequest struct { Service string // "tidal", "qobuz", "amazon" } -// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist) -// This runs in background while user is viewing the track list func PreWarmTrackCache(requests []PreWarmCacheRequest) { if len(requests) == 0 { return @@ -214,8 +186,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { wg.Add(1) go func(r PreWarmCacheRequest) { defer wg.Done() - semaphore <- struct{}{} // Acquire - defer func() { <-semaphore }() // Release + semaphore <- struct{}{} + defer func() { <-semaphore }() switch r.Service { case "tidal": @@ -259,12 +231,6 @@ func preWarmAmazonCache(isrc, spotifyID string) { } } -// ======================================== -// Exported Functions for Flutter -// ======================================== - -// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks -// tracksJSON is a JSON array of {isrc, track_name, artist_name, service} func PreWarmCache(tracksJSON string) error { var requests []PreWarmCacheRequest @@ -272,13 +238,11 @@ func PreWarmCache(tracksJSON string) error { return nil } -// ClearTrackCache clears the track ID cache func ClearTrackCache() { GetTrackIDCache().Clear() fmt.Println("[Cache] Track ID cache cleared") } -// GetCacheSize returns the current cache size func GetCacheSize() int { return GetTrackIDCache().Size() } diff --git a/go_backend/progress.go b/go_backend/progress.go index cf18b5ee..159f4d6c 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -6,8 +6,6 @@ import ( "time" ) -// DownloadProgress represents current download progress -// Now unified - returns data from multi-progress system type DownloadProgress struct { CurrentFile string `json:"current_file"` Progress float64 `json:"progress"` @@ -15,21 +13,19 @@ type DownloadProgress struct { BytesTotal int64 `json:"bytes_total"` BytesReceived int64 `json:"bytes_received"` IsDownloading bool `json:"is_downloading"` - Status string `json:"status"` // "downloading", "finalizing", "completed" + Status string `json:"status"` } -// ItemProgress represents progress for a single download item type ItemProgress struct { ItemID string `json:"item_id"` BytesTotal int64 `json:"bytes_total"` BytesReceived int64 `json:"bytes_received"` - Progress float64 `json:"progress"` // 0.0 to 1.0 - SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s + Progress float64 `json:"progress"` + SpeedMBps float64 `json:"speed_mbps"` IsDownloading bool `json:"is_downloading"` - Status string `json:"status"` // "downloading", "finalizing", "completed" + Status string `json:"status"` } -// MultiProgress holds progress for multiple concurrent downloads type MultiProgress struct { Items map[string]*ItemProgress `json:"items"` } @@ -38,12 +34,10 @@ var ( downloadDir string downloadDirMu sync.RWMutex - // Multi-download progress tracking (unified system) multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} multiMu sync.RWMutex ) -// getProgress returns current download progress from multi-progress system func getProgress() DownloadProgress { multiMu.RLock() defer multiMu.RUnlock() @@ -62,7 +56,6 @@ func getProgress() DownloadProgress { return DownloadProgress{} } -// GetMultiProgress returns progress for all active downloads as JSON func GetMultiProgress() string { multiMu.RLock() defer multiMu.RUnlock() @@ -74,7 +67,6 @@ func GetMultiProgress() string { return string(jsonBytes) } -// GetItemProgress returns progress for a specific item as JSON func GetItemProgress(itemID string) string { multiMu.RLock() defer multiMu.RUnlock() @@ -201,14 +193,6 @@ func setDownloadDir(path string) error { return nil } -// getDownloadDir returns the default download directory -// Kept for potential future use -// func getDownloadDir() string { -// downloadDirMu.RLock() -// defer downloadDirMu.RUnlock() -// return downloadDir -// } - // ItemProgressWriter wraps io.Writer to track download progress for a specific item type ItemProgressWriter struct { writer interface{ Write([]byte) (int, error) } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 0adb4a9d..d3a777ac 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -17,7 +17,6 @@ import ( "time" ) -// QobuzDownloader handles Qobuz downloads type QobuzDownloader struct { client *http.Client appID string @@ -29,7 +28,6 @@ var ( qobuzDownloaderOnce sync.Once ) -// QobuzTrack represents a Qobuz track type QobuzTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -50,7 +48,6 @@ type QobuzTrack struct { } `json:"performer"` } -// qobuzArtistsMatch checks if the artist names are similar enough func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist)) @@ -93,9 +90,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { return false } -// qobuzSplitArtists splits artist string by common separators func qobuzSplitArtists(artists string) []string { - // Replace common separators with a standard one normalized := artists normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|") @@ -154,7 +149,6 @@ func qobuzSameWordsUnordered(a, b string) bool { return true } -// qobuzTitlesMatch checks if track titles are similar enough func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle)) @@ -164,12 +158,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { return true } - // Check if one contains the other if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { return true } - // Clean BOTH titles and compare (removes suffixes like remaster, remix, etc) cleanExpected := qobuzCleanTitle(normExpected) cleanFound := qobuzCleanTitle(normFound) @@ -177,14 +169,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { return true } - // Check if cleaned versions contain each other if cleanExpected != "" && cleanFound != "" { if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { return true } } - // Extract core title (before any parentheses/brackets) coreExpected := qobuzExtractCoreTitle(normExpected) coreFound := qobuzExtractCoreTitle(normFound) @@ -225,19 +215,15 @@ func qobuzExtractCoreTitle(title string) string { return strings.TrimSpace(title[:cutIdx]) } -// qobuzCleanTitle removes common suffixes from track titles for comparison func qobuzCleanTitle(title string) string { cleaned := title - // Remove content in parentheses/brackets that are version indicators - // This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)" versionPatterns := []string{ "remaster", "remastered", "deluxe", "bonus", "single", "album version", "radio edit", "original mix", "extended", "club mix", "remix", "live", "acoustic", "demo", } - // Remove parenthetical content if it contains version indicators for { startParen := strings.LastIndex(cleaned, "(") endParen := strings.LastIndex(cleaned, ")") @@ -258,7 +244,6 @@ func qobuzCleanTitle(title string) string { break } - // Same for brackets for { startBracket := strings.LastIndex(cleaned, "[") endBracket := strings.LastIndex(cleaned, "]") @@ -279,7 +264,6 @@ func qobuzCleanTitle(title string) string { break } - // Remove trailing " - version" patterns dashPatterns := []string{ " - remaster", " - remastered", " - single version", " - radio edit", " - live", " - acoustic", " - demo", " - remix", @@ -290,7 +274,6 @@ func qobuzCleanTitle(title string) string { } } - // Remove multiple spaces for strings.Contains(cleaned, " ") { cleaned = strings.ReplaceAll(cleaned, " ", " ") } @@ -350,7 +333,6 @@ func containsQueryQobuz(queries []string, query string) bool { return false } -// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse) func NewQobuzDownloader() *QobuzDownloader { qobuzDownloaderOnce.Do(func() { globalQobuzDownloader = &QobuzDownloader{ @@ -361,7 +343,6 @@ func NewQobuzDownloader() *QobuzDownloader { return globalQobuzDownloader } -// GetTrackByID fetches track info directly by Qobuz track ID func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { // Qobuz API: /track/get?track_id=XXX apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") @@ -412,7 +393,6 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string { return apis } -// SearchTrackByISRC searches for a track by ISRC func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) @@ -455,7 +435,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } -// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification // expectedDurationSec is the expected duration in seconds (0 to skip verification) func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) @@ -500,7 +479,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches)) if len(isrcMatches) > 0 { - // Verify duration if provided if expectedDurationSec > 0 { var durationVerifiedMatches []*QobuzTrack for _, track := range isrcMatches { @@ -508,7 +486,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur if durationDiff < 0 { durationDiff = -durationDiff } - // Allow 10 seconds tolerance if durationDiff <= 10 { durationVerifiedMatches = append(durationVerifiedMatches, track) } @@ -520,14 +497,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur return durationVerifiedMatches[0], nil } - // ISRC matches but duration doesn't GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", isrc, expectedDurationSec, isrcMatches[0].Duration) return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", expectedDurationSec, isrcMatches[0].Duration) } - // No duration to verify, return first match GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) return isrcMatches[0], nil } @@ -539,17 +514,14 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } -// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) { return q.SearchTrackByISRCWithDuration(isrc, 0) } -// SearchTrackByMetadata searches for a track using artist name and track name func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) { return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) } -// SearchTrackByMetadataWithDuration searches for a track with duration verification // Now includes romaji conversion for Japanese text (same as Tidal) // Also includes title verification to prevent wrong song downloads func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { @@ -688,7 +660,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam } if len(durationMatches) > 0 { - // Return best quality among duration matches for _, track := range durationMatches { if track.MaximumBitDepth >= 24 { GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n", @@ -701,7 +672,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return durationMatches[0], nil } - // No duration match found return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec) } @@ -731,8 +701,6 @@ type qobuzAPIResult struct { duration time.Duration } -// getQobuzDownloadURLParallel requests download URL from all APIs in parallel -// "Siapa cepat dia dapat" - first successful response wins func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { if len(apis) == 0 { return "", "", fmt.Errorf("no APIs available") @@ -839,8 +807,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) } -// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel -// "Siapa cepat dia dapat" - first successful response wins func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { apis := q.GetAvailableAPIs() if len(apis) == 0 { @@ -938,7 +904,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return nil } -// QobuzDownloadResult contains download result with quality info type QobuzDownloadResult struct { FilePath string BitDepth int @@ -952,7 +917,6 @@ type QobuzDownloadResult struct { ISRC string } -// downloadFromQobuz downloads a track using the request parameters func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { downloader := NewQobuzDownloader() @@ -1135,15 +1099,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Handle lyrics based on LyricsMode setting - // Mode: "embed" (default), "external" (.lrc file), "both" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { - lyricsMode = "embed" // default + lyricsMode = "embed" } - // Save external .lrc file if mode is "external" or "both" if lyricsMode == "external" || lyricsMode == "both" { GoLog("[Qobuz] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { @@ -1153,7 +1114,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } } - // Embed lyrics if mode is "embed" or "both" if lyricsMode == "embed" || lyricsMode == "both" { GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { diff --git a/go_backend/ratelimit.go b/go_backend/ratelimit.go index 1caa54d2..1f2ac1f6 100644 --- a/go_backend/ratelimit.go +++ b/go_backend/ratelimit.go @@ -5,7 +5,6 @@ import ( "time" ) -// RateLimiter implements a sliding window rate limiter type RateLimiter struct { mu sync.Mutex maxRequests int @@ -13,7 +12,6 @@ type RateLimiter struct { timestamps []time.Time } -// NewRateLimiter creates a new rate limiter with specified max requests per window func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter { return &RateLimiter{ maxRequests: maxRequests, @@ -22,8 +20,6 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter { } } -// WaitForSlot blocks until a request is allowed under the rate limit -// Returns immediately if under the limit, otherwise waits until a slot is available func (r *RateLimiter) WaitForSlot() { r.mu.Lock() defer r.mu.Unlock() @@ -70,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) { } } -// TryAcquire attempts to acquire a slot without blocking -// Returns true if successful, false if rate limit would be exceeded func (r *RateLimiter) TryAcquire() bool { r.mu.Lock() defer r.mu.Unlock() @@ -87,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool { return false } -// Available returns the number of requests available in the current window func (r *RateLimiter) Available() int { r.mu.Lock() defer r.mu.Unlock() @@ -99,7 +92,6 @@ func (r *RateLimiter) Available() int { // Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10) var songLinkRateLimiter = NewRateLimiter(9, time.Minute) -// GetSongLinkRateLimiter returns the global SongLink rate limiter func GetSongLinkRateLimiter() *RateLimiter { return songLinkRateLimiter } diff --git a/go_backend/romaji.go b/go_backend/romaji.go index 1e1516e3..d5a73963 100644 --- a/go_backend/romaji.go +++ b/go_backend/romaji.go @@ -5,7 +5,6 @@ import ( "unicode" ) -// Hiragana to Romaji mapping var hiraganaToRomaji = map[rune]string{ 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o", 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko", @@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{ 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", } -// Katakana to Romaji mapping var katakanaToRomaji = map[rune]string{ 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o", 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko", @@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{ 'ヴ': "vu", } -// Combination mappings for きゃ, しゃ, etc. var combinationHiragana = map[string]string{ "きゃ": "kya", "きゅ": "kyu", "きょ": "kyo", "しゃ": "sha", "しゅ": "shu", "しょ": "sho", @@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{ "ウィ": "wi", "ウェ": "we", "ウォ": "wo", } -// ContainsJapanese checks if a string contains Japanese characters func ContainsJapanese(s string) bool { for _, r := range s { if isHiragana(r) || isKatakana(r) || isKanji(r) { @@ -114,8 +110,6 @@ func isKanji(r rune) bool { (r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A } -// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji -// Note: Kanji cannot be converted without a dictionary, so they are kept as-is func JapaneseToRomaji(text string) string { if !ContainsJapanese(text) { return text @@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string { return result.String() } -// BuildSearchQuery creates a search query from track name and artist -// Converts Japanese to romaji if present func BuildSearchQuery(trackName, artistName string) string { // Convert Japanese to romaji trackRomaji := JapaneseToRomaji(trackName) @@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string { return strings.TrimSpace(artistClean + " " + trackClean) } -// cleanSearchQuery removes special characters that might interfere with search func cleanSearchQuery(s string) string { var result strings.Builder for _, r := range s { @@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string { return strings.TrimSpace(result.String()) } -// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces -// This is useful for creating search queries that work better with Tidal's search func CleanToASCII(s string) string { var result strings.Builder for _, r := range s { diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 63e1bbab..f898af9a 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -11,12 +11,10 @@ import ( "time" ) -// SongLinkClient handles song.link API interactions type SongLinkClient struct { client *http.Client } -// TrackAvailability represents track availability on different platforms type TrackAvailability struct { SpotifyID string `json:"spotify_id"` Tidal bool `json:"tidal"` @@ -35,7 +33,6 @@ var ( songLinkClientOnce sync.Once ) -// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse) func NewSongLinkClient() *SongLinkClient { songLinkClientOnce.Do(func() { globalSongLinkClient = &SongLinkClient{ @@ -45,7 +42,6 @@ func NewSongLinkClient() *SongLinkClient { return globalSongLinkClient } -// CheckTrackAvailability checks track availability on streaming platforms func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { if spotifyTrackID == "" { return nil, fmt.Errorf("spotify track ID is empty") @@ -126,7 +122,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri return availability, nil } -// GetStreamingURLs gets streaming URLs for a Spotify track func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { @@ -191,7 +186,6 @@ func extractDeezerIDFromURL(deezerURL string) string { return "" } -// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { @@ -213,7 +207,6 @@ type AlbumAvailability struct { DeezerID string `json:"deezer_id,omitempty"` } -// CheckAlbumAvailability checks album availability on streaming platforms using SongLink func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { // Use global rate limiter songLinkRateLimiter.WaitForSlot() @@ -283,11 +276,6 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str return availability.DeezerID, nil } -// ======================================== -// Deezer ID Support - Query SongLink using Deezer as source -// ======================================== - -// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source // This is useful when we have Deezer metadata and want to find the track on other platforms func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) { if deezerTrackID == "" { @@ -374,7 +362,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra return availability, nil } -// CheckAvailabilityByPlatform checks track availability using any supported platform // platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc. // entityType: "song" or "album" // entityID: the ID on that platform @@ -472,7 +459,6 @@ func extractSpotifyIDFromURL(spotifyURL string) string { return "" } -// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { @@ -500,7 +486,6 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er return availability.TidalURL, nil } -// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 22f4cf99..65b37143 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -24,7 +24,6 @@ const ( artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" searchBaseURL = "https://api.spotify.com/v1/search" - // Cache TTL settings artistCacheTTL = 10 * time.Minute searchCacheTTL = 5 * time.Minute albumCacheTTL = 10 * time.Minute @@ -32,7 +31,6 @@ const ( var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") -// cacheEntry holds cached data with expiration type cacheEntry struct { data interface{} expiresAt time.Time @@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool { return time.Now().After(e.expiresAt) } -// SpotifyMetadataClient handles Spotify API interactions type SpotifyMetadataClient struct { httpClient *http.Client clientID string clientSecret string cachedToken string tokenExpiresAt time.Time - tokenMu sync.Mutex // Protects token cache for concurrent access + tokenMu sync.Mutex rng *rand.Rand rngMu sync.Mutex userAgent string - // Caches to reduce API calls - artistCache map[string]*cacheEntry // key: artistID - searchCache map[string]*cacheEntry // key: query+type - albumCache map[string]*cacheEntry // key: albumID + artistCache map[string]*cacheEntry + searchCache map[string]*cacheEntry + albumCache map[string]*cacheEntry cacheMu sync.RWMutex } -// Custom credentials storage (set from Flutter) var ( customClientID string customClientSecret string @@ -79,7 +74,6 @@ func SetSpotifyCredentials(clientID, clientSecret string) { customClientSecret = clientSecret } -// HasSpotifyCredentials checks if Spotify credentials are configured func HasSpotifyCredentials() bool { credentialsMu.RLock() defer credentialsMu.RUnlock() @@ -114,8 +108,6 @@ func getCredentials() (string, string, error) { return "", "", ErrNoSpotifyCredentials } -// NewSpotifyMetadataClient creates a new Spotify client -// Returns error if credentials are not configured func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { clientID, clientSecret, err := getCredentials() if err != nil { @@ -137,7 +129,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { return c, nil } -// TrackMetadata represents track information type TrackMetadata struct { SpotifyID string `json:"spotify_id,omitempty"` Artists string `json:"artists"` @@ -155,7 +146,6 @@ type TrackMetadata struct { AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation } -// AlbumTrackMetadata holds per-track info for album/playlist type AlbumTrackMetadata struct { SpotifyID string `json:"spotify_id,omitempty"` Artists string `json:"artists"` @@ -172,28 +162,25 @@ type AlbumTrackMetadata struct { ISRC string `json:"isrc"` AlbumID string `json:"album_id,omitempty"` AlbumURL string `json:"album_url,omitempty"` - AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation + AlbumType string `json:"album_type,omitempty"` } -// AlbumInfoMetadata holds album information type AlbumInfoMetadata struct { TotalTracks int `json:"total_tracks"` Name string `json:"name"` ReleaseDate string `json:"release_date"` Artists string `json:"artists"` Images string `json:"images"` - Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated - Label string `json:"label,omitempty"` // Record label name - Copyright string `json:"copyright,omitempty"` // Copyright information + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` } -// AlbumResponsePayload is the response for album requests type AlbumResponsePayload struct { AlbumInfo AlbumInfoMetadata `json:"album_info"` TrackList []AlbumTrackMetadata `json:"track_list"` } -// PlaylistInfoMetadata holds playlist information type PlaylistInfoMetadata struct { Tracks struct { Total int `json:"total"` @@ -205,13 +192,11 @@ type PlaylistInfoMetadata struct { } `json:"owner"` } -// PlaylistResponsePayload is the response for playlist requests type PlaylistResponsePayload struct { PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` TrackList []AlbumTrackMetadata `json:"track_list"` } -// ArtistInfoMetadata holds artist information type ArtistInfoMetadata struct { ID string `json:"id"` Name string `json:"name"` @@ -220,7 +205,6 @@ type ArtistInfoMetadata struct { Popularity int `json:"popularity"` } -// ArtistAlbumMetadata holds album info for artist discography type ArtistAlbumMetadata struct { ID string `json:"id"` Name string `json:"name"` @@ -231,24 +215,20 @@ type ArtistAlbumMetadata struct { Artists string `json:"artists"` } -// ArtistResponsePayload is the response for artist requests type ArtistResponsePayload struct { ArtistInfo ArtistInfoMetadata `json:"artist_info"` Albums []ArtistAlbumMetadata `json:"albums"` } -// TrackResponse is the response for single track requests type TrackResponse struct { Track TrackMetadata `json:"track"` } -// SearchResult represents search results type SearchResult struct { Tracks []TrackMetadata `json:"tracks"` Total int `json:"total"` } -// SearchArtistResult represents an artist in search results type SearchArtistResult struct { ID string `json:"id"` Name string `json:"name"` @@ -257,7 +237,6 @@ type SearchArtistResult struct { Popularity int `json:"popularity"` } -// SearchAllResult represents combined search results for tracks and artists type SearchAllResult struct { Tracks []TrackMetadata `json:"tracks"` Artists []SearchArtistResult `json:"artists"` @@ -274,7 +253,6 @@ type accessTokenResponse struct { TokenType string `json:"token_type"` } -// Internal API response types type image struct { URL string `json:"url"` } @@ -300,7 +278,7 @@ type albumSimplified struct { Images []image `json:"images"` ExternalURL externalURL `json:"external_urls"` Artists []artist `json:"artists"` - AlbumType string `json:"album_type"` // album, single, compilation + AlbumType string `json:"album_type"` } type trackFull struct { @@ -315,7 +293,6 @@ type trackFull struct { Artists []artist `json:"artists"` } -// GetFilteredData fetches and formats Spotify data func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { parsed, err := parseSpotifyURI(spotifyURL) if err != nil { @@ -341,7 +318,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL } } -// SearchTracks searches for tracks on Spotify func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) { token, err := c.getAccessToken(ctx) if err != nil { @@ -388,7 +364,6 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, return result, nil } -// SearchAll searches for tracks and artists on Spotify func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit) @@ -510,7 +485,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s } c.cacheMu.RUnlock() - // Track item structure for pagination type trackItem struct { ID string `json:"id"` Name string `json:"name"` @@ -546,11 +520,9 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s Images: albumImage, } - // Collect all tracks (including paginated) allTrackItems := data.Tracks.Items nextURL := data.Tracks.Next - // Fetch remaining tracks using pagination (no limit) for nextURL != "" { var pageData struct { Items []trackItem `json:"items"` @@ -572,7 +544,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s trackIDs[i] = item.ID } - // Fetch ISRCs in parallel for ALL tracks (like Deezer implementation) isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token) tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems)) @@ -612,10 +583,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s return result, nil } -// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel -// Similar to Deezer implementation for consistency func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string { - const maxParallelISRC = 10 // Max concurrent ISRC fetches + const maxParallelISRC = 10 result := make(map[string]string) var resultMu sync.Mutex @@ -624,7 +593,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs return result } - // Use semaphore to limit concurrent requests sem := make(chan struct{}, maxParallelISRC) var wg sync.WaitGroup @@ -633,7 +601,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs go func(id string) { defer wg.Done() - // Acquire semaphore select { case sem <- struct{}{}: defer func() { <-sem }() @@ -654,7 +621,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs } func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { - // First request to get playlist info and first batch of tracks var data struct { Name string `json:"name"` Images []image `json:"images"` @@ -680,10 +646,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t info.Owner.Name = data.Name info.Owner.Images = firstImageURL(data.Images) - // Pre-allocate with expected capacity tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total) - // Add first batch of tracks for _, item := range data.Tracks.Items { if item.Track == nil { continue @@ -707,7 +671,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t }) } - // Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks) nextURL := data.Tracks.Next for nextURL != "" { @@ -719,7 +682,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t } if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil { - // Log error but return what we have so far fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err) break } @@ -766,7 +728,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token } c.cacheMu.RUnlock() - // Fetch artist info var artistData struct { ID string `json:"id"` Name string `json:"name"` @@ -789,7 +750,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token Popularity: artistData.Popularity, } - // Fetch artist albums (all types: album, single, compilation) albums := make([]ArtistAlbumMetadata, 0) offset := 0 limit := 50 @@ -829,13 +789,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token }) } - // Check if there are more albums if albumsData.Next == "" || len(albumsData.Items) < limit { break } offset += limit - // Safety limit to prevent infinite loops if offset > 500 { break } @@ -916,7 +874,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str return err } - // Set headers (same as PC version baseHeaders) req.Header.Set("User-Agent", c.userAgent) req.Header.Set("Accept", "application/json") req.Header.Set("Accept-Language", "en-US,en;q=0.9") @@ -952,8 +909,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string { c.rngMu.Lock() defer c.rngMu.Unlock() - // Use Mac User-Agent format (same as PC version) - macMajor := c.rng.Intn(4) + 11 // 11-14 + macMajor := c.rng.Intn(4) + 11 macMinor := c.rng.Intn(5) + 4 // 4-8 webkitMajor := c.rng.Intn(7) + 530 // 530-536 webkitMinor := c.rng.Intn(7) + 30 // 30-36 @@ -978,7 +934,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { return spotifyURI{}, errInvalidSpotifyURL } - // Handle spotify: URI format if strings.HasPrefix(trimmed, "spotify:") { parts := strings.Split(trimmed, ":") if len(parts) == 3 { @@ -989,13 +944,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) { } } - // Handle URL format parsed, err := url.Parse(trimmed) if err != nil { return spotifyURI{}, err } - // Handle embed.spotify.com URLs if parsed.Host == "embed.spotify.com" { if parsed.RawQuery == "" { return spotifyURI{}, errInvalidSpotifyURL @@ -1008,7 +961,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { return parseSpotifyURI(embedded) } - // Handle plain ID (no scheme/host) - defaults to playlist if parsed.Scheme == "" && parsed.Host == "" { id := strings.Trim(strings.TrimSpace(parsed.Path), "/") if id == "" { @@ -1034,7 +986,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { return spotifyURI{}, errInvalidSpotifyURL } - // Skip intl- prefix if present if strings.HasPrefix(parts[0], "intl-") { parts = parts[1:] } @@ -1042,7 +993,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { return spotifyURI{}, errInvalidSpotifyURL } - // Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id} if len(parts) == 2 { switch parts[0] { case "album", "track", "playlist", "artist": @@ -1050,7 +1000,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { } } - // Handle nested playlist URLs: /user/{user}/playlist/{id} if len(parts) == 4 && parts[2] == "playlist" { return spotifyURI{Type: "playlist", ID: parts[3]}, nil } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index aeeef441..8c245b64 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -19,7 +19,6 @@ import ( "time" ) -// TidalDownloader handles Tidal downloads type TidalDownloader struct { client *http.Client clientID string @@ -35,7 +34,6 @@ var ( tidalDownloaderOnce sync.Once ) -// TidalTrack represents a Tidal track type TidalTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -60,7 +58,6 @@ type TidalTrack struct { } `json:"mediaMetadata"` } -// TidalAPIResponseV2 is the new API response format (version 2.0) type TidalAPIResponseV2 struct { Version string `json:"version"` Data struct { @@ -76,7 +73,6 @@ type TidalAPIResponseV2 struct { } `json:"data"` } -// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format type TidalBTSManifest struct { MimeType string `json:"mimeType"` Codecs string `json:"codecs"` @@ -84,7 +80,6 @@ type TidalBTSManifest struct { URLs []string `json:"urls"` } -// MPD represents DASH manifest structure type MPD struct { XMLName xml.Name `xml:"MPD"` Period struct { @@ -105,7 +100,6 @@ type MPD struct { } `xml:"Period"` } -// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse) func NewTidalDownloader() *TidalDownloader { tidalDownloaderOnce.Do(func() { clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") @@ -150,7 +144,6 @@ func (t *TidalDownloader) GetAvailableAPIs() []string { return apis } -// GetAccessToken gets Tidal access token (with caching) func (t *TidalDownloader) GetAccessToken() (string, error) { t.tokenMu.Lock() defer t.tokenMu.Unlock() @@ -199,7 +192,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) { return result.AccessToken, nil } -// GetTidalURLFromSpotify gets Tidal URL from Spotify track ID using SongLink func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) @@ -239,7 +231,6 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, return tidalLink.URL, nil } -// GetTrackIDFromURL extracts track ID from Tidal URL func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { parts := strings.Split(tidalURL, "/track/") if len(parts) < 2 { @@ -293,7 +284,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { return &trackInfo, nil } -// SearchTrackByISRC searches for a track by ISRC func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { token, err := t.GetAccessToken() if err != nil { @@ -341,30 +331,7 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } -// normalizeTitle normalizes a track title for comparison -// Kept for potential future use -// func normalizeTitle(title string) string { -// normalized := strings.ToLower(strings.TrimSpace(title)) -// -// // Remove common suffixes in parentheses or brackets -// suffixPatterns := []string{ -// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)", -// " (bonus track)", " (single)", " (album version)", " (radio edit)", -// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]", -// } -// for _, suffix := range suffixPatterns { -// normalized = strings.TrimSuffix(normalized, suffix) -// } -// -// // Remove multiple spaces -// for strings.Contains(normalized, " ") { -// normalized = strings.ReplaceAll(normalized, " ", " ") -// } -// -// return normalized -// } -// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority // Now includes romaji conversion for Japanese text (4 search strategies like PC) func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { token, err := t.GetAccessToken() @@ -466,7 +433,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s if len(result.Items) > 0 { GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) - // OPTIMIZATION: If ISRC provided, check for match immediately and return early if spotifyISRC != "" { for i := range result.Items { if result.Items[i].ISRC == spotifyISRC { @@ -592,7 +558,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s return bestMatch, nil } -// containsQuery checks if a query already exists in the list func containsQuery(queries []string, query string) bool { for _, q := range queries { if q == query { @@ -602,7 +567,6 @@ func containsQuery(queries []string, query string) bool { return false } -// SearchTrackByMetadata searches for a track using artist name and track name func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0) } @@ -614,7 +578,6 @@ type TidalDownloadInfo struct { SampleRate int } -// tidalAPIResult holds the result from a parallel API request type tidalAPIResult struct { apiURL string info TidalDownloadInfo @@ -622,9 +585,7 @@ type tidalAPIResult struct { duration time.Duration } -// getDownloadURLParallel requests download URL from all APIs in parallel // Returns the first successful result (supports both v1 and v2 API formats) -// "Siapa cepat dia dapat" - first success wins func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { if len(apis) == 0 { return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") @@ -671,8 +632,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - // IMPORTANT: Reject PREVIEW responses - we need FULL tracks - if v2Response.Data.AssetPresentation == "PREVIEW" { + if v2Response.Data.AssetPresentation == "PREVIEW" { resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} return } @@ -715,7 +675,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n", result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration) - // Don't return immediately - drain remaining results to avoid goroutine leaks go func(remaining int) { for j := 0; j < remaining; j++ { <-resultChan @@ -736,8 +695,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) } -// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel -// "Siapa cepat dia dapat" - first successful response wins func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) { apis := t.GetAvailableAPIs() if len(apis) == 0 { @@ -752,7 +709,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo return info, nil } -// parseManifest parses Tidal manifest (supports both BTS and DASH formats) func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) if err != nil { @@ -859,7 +815,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID) } - // Initialize item progress for direct downloads if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) @@ -952,9 +907,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, if directURL != "" { GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) - // Note: Progress tracking is initialized by the caller (DownloadFile) - - if isDownloadCancelled(itemID) { + if isDownloadCancelled(itemID) { return ErrDownloadCancelled } @@ -1135,7 +1088,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, return nil } -// TidalDownloadResult contains download result with quality info type TidalDownloadResult struct { FilePath string BitDepth int @@ -1149,12 +1101,10 @@ type TidalDownloadResult struct { ISRC string } -// artistsMatch checks if the artist names are similar enough func artistsMatch(spotifyArtist, tidalArtist string) bool { normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist)) normTidal := strings.ToLower(strings.TrimSpace(tidalArtist)) - // Exact match if normSpotify == normTidal { return true } @@ -1164,22 +1114,17 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return true } - // Split artists by common separators (comma, feat, ft., &, and) - // e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura" spotifyArtists := splitArtists(normSpotify) tidalArtists := splitArtists(normTidal) - // Check if ANY expected artist matches ANY found artist for _, exp := range spotifyArtists { for _, fnd := range tidalArtists { if exp == fnd { return true } - // Also check contains for partial matches if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { return true } - // Check same words different order if sameWordsUnordered(exp, fnd) { GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) return true @@ -1187,9 +1132,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { } } - // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) - // Don't treat Latin Extended (Polish, French, etc.) as different script - // This handles cases like "鈴木雅之" vs "Masayuki Suzuki" spotifyLatin := isLatinScript(spotifyArtist) tidalLatin := isLatinScript(tidalArtist) if spotifyLatin != tidalLatin { @@ -1200,9 +1142,7 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return false } -// splitArtists splits artist string by common separators func splitArtists(artists string) []string { - // Replace common separators with a standard one normalized := artists normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|") @@ -1224,8 +1164,6 @@ func splitArtists(artists string) []string { return result } -// sameWordsUnordered checks if two strings have the same words regardless of order -// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano" func sameWordsUnordered(a, b string) bool { wordsA := strings.Fields(a) wordsB := strings.Fields(b) @@ -1235,13 +1173,11 @@ func sameWordsUnordered(a, b string) bool { return false } - // Sort and compare sortedA := make([]string, len(wordsA)) sortedB := make([]string, len(wordsB)) copy(sortedA, wordsA) copy(sortedB, wordsB) - // Simple bubble sort (usually just 2-3 words) for i := 0; i < len(sortedA)-1; i++ { for j := i + 1; j < len(sortedA); j++ { if sortedA[i] > sortedA[j] { @@ -1261,7 +1197,6 @@ func sameWordsUnordered(a, b string) bool { return true } -// titlesMatch checks if track titles are similar enough func titlesMatch(expectedTitle, foundTitle string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle)) @@ -1271,7 +1206,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } - // Check if one contains the other if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { return true } @@ -1284,7 +1218,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } - // Check if cleaned versions contain each other if cleanExpected != "" && cleanFound != "" { if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { return true @@ -1299,7 +1232,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } - // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) // Don't treat Latin Extended (Polish, French, etc.) as different script expectedLatin := isLatinScript(expectedTitle) foundLatin := isLatinScript(foundTitle) @@ -1311,9 +1243,7 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return false } -// extractCoreTitle extracts the main title before any parentheses or brackets func extractCoreTitle(title string) string { - // Find first occurrence of ( or [ parenIdx := strings.Index(title, "(") bracketIdx := strings.Index(title, "[") dashIdx := strings.Index(title, " - ") @@ -1332,18 +1262,15 @@ func extractCoreTitle(title string) string { return strings.TrimSpace(title[:cutIdx]) } -// cleanTitle removes common suffixes from track titles for comparison func cleanTitle(title string) string { cleaned := title - // Version indicators to remove from parentheses/brackets versionPatterns := []string{ "remaster", "remastered", "deluxe", "bonus", "single", "album version", "radio edit", "original mix", "extended", "club mix", "remix", "live", "acoustic", "demo", } - // Remove parenthetical content if it contains version indicators for { startParen := strings.LastIndex(cleaned, "(") endParen := strings.LastIndex(cleaned, ")") @@ -1364,7 +1291,6 @@ func cleanTitle(title string) string { break } - // Same for brackets for { startBracket := strings.LastIndex(cleaned, "[") endBracket := strings.LastIndex(cleaned, "]") @@ -1385,7 +1311,6 @@ func cleanTitle(title string) string { break } - // Remove trailing " - version" patterns dashPatterns := []string{ " - remaster", " - remastered", " - single version", " - radio edit", " - live", " - acoustic", " - demo", " - remix", @@ -1396,7 +1321,6 @@ func cleanTitle(title string) string { } } - // Remove multiple spaces for strings.Contains(cleaned, " ") { cleaned = strings.ReplaceAll(cleaned, " ", " ") } @@ -1404,48 +1328,29 @@ func cleanTitle(title string) string { return strings.TrimSpace(cleaned) } -// isLatinScript checks if a string is primarily Latin script -// Returns true for ASCII and Latin Extended characters (European languages) -// Returns false for CJK, Arabic, Cyrillic, etc. func isLatinScript(s string) bool { for _, r := range s { - // Skip common punctuation and numbers if r < 128 { continue } - // Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.) - // Latin Extended-B: U+0180 to U+024F - // Latin Extended Additional: U+1E00 to U+1EFF - if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B - (r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional - (r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars) + if (r >= 0x0100 && r <= 0x024F) || + (r >= 0x1E00 && r <= 0x1EFF) || + (r >= 0x00C0 && r <= 0x00FF) { continue } - // CJK ranges - definitely different script - if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs - (r >= 0x3040 && r <= 0x309F) || // Hiragana - (r >= 0x30A0 && r <= 0x30FF) || // Katakana - (r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean) - (r >= 0x0600 && r <= 0x06FF) || // Arabic - (r >= 0x0400 && r <= 0x04FF) { // Cyrillic + if (r >= 0x4E00 && r <= 0x9FFF) || + (r >= 0x3040 && r <= 0x309F) || + (r >= 0x30A0 && r <= 0x30FF) || + (r >= 0xAC00 && r <= 0xD7AF) || + (r >= 0x0600 && r <= 0x06FF) || + (r >= 0x0400 && r <= 0x04FF) { return false } } return true } -// isASCIIString checks if a string contains only ASCII characters -// Kept for potential future use -// func isASCIIString(s string) bool { -// for _, r := range s { -// if r > 127 { -// return false -// } -// } -// return true -// } -// downloadFromTidal downloads a track using the request parameters func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { downloader := NewTidalDownloader() @@ -1453,16 +1358,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil } - // Convert expected duration from ms to seconds expectedDurationSec := req.DurationMS / 1000 var track *TidalTrack var err error - // STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority) if req.TidalID != "" { GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID) - // Parse track ID (could be a number or extracted from URL) var trackID int64 if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { track, err = downloader.GetTrackInfoByID(trackID) @@ -1475,7 +1377,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // OPTIMIZATION: Check cache first for track ID if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) @@ -1487,8 +1388,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC) - // Strategy 1: Search by metadata, match by ISRC (most accurate) if track == nil && req.ISRC != "" { GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) @@ -1510,7 +1409,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // Strategy 2: Try SongLink if we have Spotify ID if track == nil && req.SpotifyID != "" { GoLog("[Tidal] ISRC search failed, trying SongLink...\n") var tidalURL string @@ -1545,13 +1443,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { track = nil } - // Verify duration if we have expected duration if track != nil && expectedDurationSec > 0 { durationDiff := track.Duration - expectedDurationSec if durationDiff < 0 { durationDiff = -durationDiff } - // Allow 3 seconds tolerance (same as PC version) if durationDiff > 3 { GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", expectedDurationSec, track.Duration) @@ -1563,11 +1459,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // Strategy 3: Search by metadata only (no ISRC requirement) - last resort if track == nil { GoLog("[Tidal] Trying metadata search as last resort...\n") track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) - // Verify artist AND title for metadata search if track != nil { tidalArtist := track.Artist.Name if len(track.Artists) > 0 { @@ -1578,7 +1472,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { tidalArtist = strings.Join(artistNames, ", ") } - // Verify title first if !titlesMatch(req.TrackName, track.Title) { GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", req.TrackName, track.Title) @@ -1599,7 +1492,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) } - // Final verification logging tidalArtist := track.Artist.Name if len(track.Artists) > 0 { var artistNames []string @@ -1633,7 +1525,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil } - // Clean up any leftover .tmp files from previous failed downloads tmpPath := outputPath + ".m4a.tmp" if _, err := os.Stat(tmpPath); err == nil { GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) @@ -1651,10 +1542,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } - // Log actual quality received GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate) - // START PARALLEL: Fetch cover and lyrics while downloading audio var parallelResult *ParallelDownloadResult parallelDone := make(chan struct{}) go func() { @@ -1670,7 +1559,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { ) }() - // Download audio file with item ID for progress tracking GoLog("[Tidal] Starting download to: %s\n", outputPath) GoLog("[Tidal] Download URL type: %s\n", func() string { if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") { @@ -1688,7 +1576,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } fmt.Println("[Tidal] Download completed successfully") - // Wait for parallel operations to complete <-parallelDone if req.ItemID != "" { @@ -1701,12 +1588,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { actualOutputPath = m4aPath GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) } else if _, err := os.Stat(outputPath); err != nil { - // Neither FLAC nor M4A exists return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) } - // Embed metadata using parallel-fetched cover data - // Use release date from Tidal API if not provided in request releaseDate := req.ReleaseDate if releaseDate == "" && track.Album.ReleaseDate != "" { releaseDate = track.Album.ReleaseDate @@ -1719,13 +1603,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { Album: req.AlbumName, AlbumArtist: req.AlbumArtist, Date: releaseDate, - TrackNumber: track.TrackNumber, // Use actual track number from Tidal + TrackNumber: track.TrackNumber, TotalTracks: req.TotalTracks, - DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal - ISRC: track.ISRC, // Use actual ISRC from Tidal - Genre: req.Genre, // From Deezer album metadata - Label: req.Label, // From Deezer album metadata - Copyright: req.Copyright, // From Deezer album metadata + DiscNumber: track.VolumeNumber, + ISRC: track.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } var coverData []byte @@ -1734,21 +1618,17 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } - // Embed metadata based on file type if strings.HasSuffix(actualOutputPath, ".flac") { if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Handle lyrics based on LyricsMode setting - // Mode: "embed" (default), "external" (.lrc file), "both" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { - lyricsMode = "embed" // default + lyricsMode = "embed" } - // Save external .lrc file if mode is "external" or "both" if lyricsMode == "external" || lyricsMode == "both" { GoLog("[Tidal] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { @@ -1758,7 +1638,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // Embed lyrics if mode is "embed" or "both" if lyricsMode == "embed" || lyricsMode == "both" { GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { @@ -1771,28 +1650,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Println("[Tidal] No lyrics available from parallel fetch") } } else if strings.HasSuffix(actualOutputPath, ".m4a") { - // Embed metadata to M4A file - // GoLog("[Tidal] Embedding metadata to M4A file...\n") - - // Add lyrics to metadata if available - // if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - // metadata.Lyrics = parallelResult.LyricsLRC - // } - - // SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion - // M4A files from DASH are often fragmented and editing metadata might corrupt the container - // structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter. - fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") - - // if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil { - // GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err) - // } else { - // fmt.Println("[Tidal] M4A metadata embedded successfully") - // } } - // Add to ISRC index for fast duplicate checking AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) return TidalDownloadResult{ diff --git a/lib/main.dart b/lib/main.dart index 411235ea..37602511 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,11 +12,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Initialize services - CoverCacheManager MUST complete before app starts await CoverCacheManager.initialize(); debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}'); - // These can run in parallel await Future.wait([ NotificationService().initialize(), ShareIntentService().initialize(), diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index 2deb8f8c..fc17705c 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart'; part 'download_item.g.dart'; -/// Download status enum enum DownloadStatus { queued, downloading, - finalizing, // Embedding metadata, cover, lyrics + finalizing, completed, failed, skipped, } -/// Error type enum for better error handling enum DownloadErrorType { unknown, - notFound, // Track not found on any service - rateLimit, // Rate limited by service - network, // Network/connection error - permission, // File/folder permission error + notFound, + rateLimit, + network, + permission, } @JsonSerializable() @@ -29,7 +27,7 @@ class DownloadItem { final String service; final DownloadStatus status; final double progress; - final double speedMBps; // Download speed in MB/s + final double speedMBps; final String? filePath; final String? error; final DownloadErrorType? errorType; @@ -78,7 +76,6 @@ class DownloadItem { ); } - /// Get user-friendly error message based on error type String get errorMessage { if (error == null) return ''; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 00e308d0..892fd528 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -12,27 +12,27 @@ class AppSettings { final bool embedLyrics; final bool maxQualityCover; final bool isFirstLaunch; - final int concurrentDownloads; // 1 = sequential (default), max 3 - final bool checkForUpdates; // Check for updates on app start - final String updateChannel; // stable, preview - final bool hasSearchedBefore; // Hide helper text after first search - final String folderOrganization; // none, artist, album, artist_album - final String historyViewMode; // list, grid - final String historyFilterMode; // all, albums, singles - final bool askQualityBeforeDownload; // Show quality picker before each download - final String spotifyClientId; // Custom Spotify client ID (empty = use default) - final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) - final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) - final String metadataSource; // spotify, deezer - source for search and metadata - final bool enableLogging; // Enable detailed logging for debugging - final bool useExtensionProviders; // Use extension providers for downloads when available - final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID - final bool separateSingles; // Separate singles/EPs into their own folder - final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album - final bool showExtensionStore; // Show Extension Store tab in navigation - final String locale; // App language: 'system', 'en', 'id', etc. - final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion) - final String lyricsMode; // embed, external, both - how to save lyrics + final int concurrentDownloads; + final bool checkForUpdates; + final String updateChannel; + final bool hasSearchedBefore; + final String folderOrganization; + final String historyViewMode; + final String historyFilterMode; + final bool askQualityBeforeDownload; + final String spotifyClientId; + final String spotifyClientSecret; + final bool useCustomSpotifyCredentials; + final String metadataSource; + final bool enableLogging; + final bool useExtensionProviders; + final String? searchProvider; + final bool separateSingles; + final String albumFolderStructure; + final bool showExtensionStore; + final String locale; + final bool enableMp3Option; + final String lyricsMode; const AppSettings({ this.defaultService = 'tidal', @@ -43,27 +43,27 @@ class AppSettings { this.embedLyrics = true, this.maxQualityCover = true, this.isFirstLaunch = true, - this.concurrentDownloads = 1, // Default: sequential (off) - this.checkForUpdates = true, // Default: enabled - this.updateChannel = 'stable', // Default: stable releases only - this.hasSearchedBefore = false, // Default: show helper text - this.folderOrganization = 'none', // Default: no folder organization - this.historyViewMode = 'grid', // Default: grid view - this.historyFilterMode = 'all', // Default: show all - this.askQualityBeforeDownload = true, // Default: ask quality before download - this.spotifyClientId = '', // Default: use built-in credentials - this.spotifyClientSecret = '', // Default: use built-in credentials - this.useCustomSpotifyCredentials = true, // Default: use custom if set - this.metadataSource = 'deezer', // Default: Deezer (no rate limit) - this.enableLogging = false, // Default: disabled for performance - this.useExtensionProviders = true, // Default: use extensions when available - this.searchProvider, // Default: null (use Deezer/Spotify) - this.separateSingles = false, // Default: disabled - this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album - this.showExtensionStore = true, // Default: show store - this.locale = 'system', // Default: follow system language - this.enableMp3Option = false, // Default: disabled - this.lyricsMode = 'embed', // Default: embed lyrics into file + this.concurrentDownloads = 1, + this.checkForUpdates = true, + this.updateChannel = 'stable', + this.hasSearchedBefore = false, + this.folderOrganization = 'none', + this.historyViewMode = 'grid', + this.historyFilterMode = 'all', + this.askQualityBeforeDownload = true, + this.spotifyClientId = '', + this.spotifyClientSecret = '', + this.useCustomSpotifyCredentials = true, + this.metadataSource = 'deezer', + this.enableLogging = false, + this.useExtensionProviders = true, + this.searchProvider, + this.separateSingles = false, + this.albumFolderStructure = 'artist_album', + this.showExtensionStore = true, + this.locale = 'system', + this.enableMp3Option = false, + this.lyricsMode = 'embed', }); AppSettings copyWith({ @@ -90,7 +90,7 @@ class AppSettings { bool? enableLogging, bool? useExtensionProviders, String? searchProvider, - bool clearSearchProvider = false, // Set to true to clear searchProvider to null + bool clearSearchProvider = false, bool? separateSingles, String? albumFolderStructure, bool? showExtensionStore, diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index 55381fab..6860c67e 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled'; /// Default Spotify green color for fallback const int kDefaultSeedColor = 0xFF1DB954; -/// Theme settings model for Material Expressive 3 class ThemeSettings { final ThemeMode themeMode; final bool useDynamicColor; @@ -23,10 +22,8 @@ class ThemeSettings { this.useAmoled = false, }); - /// Get seed color as Color object Color get seedColor => Color(seedColorValue); - /// Create a copy with updated values ThemeSettings copyWith({ ThemeMode? themeMode, bool? useDynamicColor, @@ -41,7 +38,6 @@ class ThemeSettings { ); } - /// Convert to JSON map for persistence Map toJson() => { kThemeModeKey: themeMode.name, kUseDynamicColorKey: useDynamicColor, @@ -49,7 +45,6 @@ class ThemeSettings { kUseAmoledKey: useAmoled, }; - /// Create from JSON map factory ThemeSettings.fromJson(Map json) { return ThemeSettings( themeMode: _themeModeFromString(json[kThemeModeKey] as String?), @@ -74,7 +69,6 @@ class ThemeSettings { themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode; } -/// Helper to convert string to ThemeMode ThemeMode _themeModeFromString(String? value) { if (value == null) return ThemeMode.system; return ThemeMode.values.firstWhere( diff --git a/lib/models/track.dart b/lib/models/track.dart index dda110b7..d2ab69fe 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart'; part 'track.g.dart'; -/// Track model representing a music track @JsonSerializable() class Track { final String id; @@ -18,9 +17,9 @@ class Track { final String? releaseDate; final String? deezerId; final ServiceAvailability? availability; - final String? source; // Extension ID that provided this track (null for built-in sources) - final String? albumType; // album, single, ep, compilation (from metadata API) - final String? itemType; // track, album, playlist - for extension search results + final String? source; + final String? albumType; + final String? itemType; const Track({ required this.id, @@ -41,25 +40,19 @@ class Track { this.itemType, }); - /// Check if this track is a single (based on album_type metadata) bool get isSingle => albumType == 'single' || albumType == 'ep'; - /// Check if this is an album item (not a track) bool get isAlbumItem => itemType == 'album'; - /// Check if this is a playlist item (not a track) bool get isPlaylistItem => itemType == 'playlist'; - /// Check if this is an artist item (not a track) bool get isArtistItem => itemType == 'artist'; - /// Check if this is a collection (album, playlist, or artist) bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem; factory Track.fromJson(Map json) => _$TrackFromJson(json); Map toJson() => _$TrackToJson(this); - /// Check if this track is from an extension bool get isFromExtension => source != null && source!.isNotEmpty; } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index e1e27bdc..ab7c7eaa 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -125,7 +125,7 @@ class DownloadHistoryItem { class DownloadHistoryState { final List items; - final Set _downloadedSpotifyIds; // Cache for O(1) lookup + final Set _downloadedSpotifyIds; DownloadHistoryState({this.items = const []}) : _downloadedSpotifyIds = items @@ -133,7 +133,6 @@ class DownloadHistoryState { .map((item) => item.spotifyId!) .toSet(); - /// Check if a track has been downloaded (by Spotify ID) bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId); @@ -188,7 +187,6 @@ class DownloadHistoryNotifier extends Notifier { } } - /// Deduplicate history items by spotifyId, deezerId, or ISRC /// Keeps the most recent entry (first occurrence since list is sorted by date desc) List _deduplicateHistory(List items) { final seen = {}; // key -> index of first occurrence @@ -234,7 +232,6 @@ class DownloadHistoryNotifier extends Notifier { } } - /// Force reload from storage (useful after app restart) Future reloadFromStorage() async { await _loadFromStorage(); } @@ -285,7 +282,6 @@ class DownloadHistoryNotifier extends Notifier { _saveToStorage(); } - /// Remove item from history by Spotify ID void removeBySpotifyId(String spotifyId) { state = state.copyWith( items: state.items.where((item) => item.spotifyId != spotifyId).toList(), @@ -294,7 +290,6 @@ class DownloadHistoryNotifier extends Notifier { _historyLog.d('Removed item with spotifyId: $spotifyId'); } - /// Get history item by Spotify ID DownloadHistoryItem? getBySpotifyId(String spotifyId) { return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull; } @@ -314,12 +309,12 @@ class DownloadQueueState { final List items; final DownloadItem? currentDownload; final bool isProcessing; - final bool isPaused; // NEW: pause state + final bool isPaused; final String outputDir; final String filenameFormat; - final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS + final String audioQuality; final bool autoFallback; - final int concurrentDownloads; // 1 = sequential, max 3 + final int concurrentDownloads; const DownloadQueueState({ this.items = const [], @@ -386,14 +381,13 @@ class _ProgressUpdate { class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; - int _downloadCount = 0; // Counter for connection cleanup - static const _cleanupInterval = 50; // Cleanup every 50 downloads - static const _queueStorageKey = - 'download_queue'; // Storage key for queue persistence + int _downloadCount = 0; + static const _cleanupInterval = 50; + static const _queueStorageKey = 'download_queue'; final NotificationService _notificationService = NotificationService(); - int _totalQueuedAtStart = 0; // Track total items when queue started - int _completedInSession = 0; // Track completed downloads in current session - int _failedInSession = 0; // Track failed downloads in current session + int _totalQueuedAtStart = 0; + int _completedInSession = 0; + int _failedInSession = 0; bool _isLoaded = false; final Set _ensuredDirs = {}; @@ -411,7 +405,6 @@ class DownloadQueueNotifier extends Notifier { return const DownloadQueueState(); } - /// Load persisted queue from storage (for app restart recovery) Future _loadQueueFromStorage() async { if (_isLoaded) return; _isLoaded = true; @@ -453,7 +446,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Save current queue to storage (only pending items) Future _saveQueueToStorage() async { try { final prefs = await SharedPreferences.getInstance(); @@ -479,7 +471,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Start multi-progress polling for all downloads (sequential and parallel) void _startMultiProgressPolling() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(const Duration(milliseconds: 500), ( @@ -607,7 +598,7 @@ class DownloadQueueNotifier extends Notifier { trackName: finalizingTrackName, artistName: finalizingArtistName ?? '', ); - return; // Don't show download progress notification + return; } if (items.isNotEmpty) { @@ -651,14 +642,11 @@ class DownloadQueueNotifier extends Notifier { progress: notifProgress, total: notifTotal > 0 ? notifTotal : 1, queueCount: state.queuedCount, - ).catchError((_) {}); // Ignore errors + ).catchError((_) {}); } } } - } catch (e) { - // Silently ignore polling errors to avoid spamming logs - // Polling is not critical and will retry on next interval - } + } catch (_) {} }); } @@ -725,7 +713,6 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: dir); } - /// Build output directory based on folder organization setting and separateSingles Future _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async { String baseDir = state.outputDir; final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName; @@ -794,7 +781,6 @@ class DownloadQueueNotifier extends Notifier { return baseDir; } - /// Sanitize folder names (remove invalid characters) String _sanitizeFolderName(String name) { return name .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') @@ -866,7 +852,7 @@ class DownloadQueueNotifier extends Notifier { }).toList(); state = state.copyWith(items: [...state.items, ...newItems]); - _saveQueueToStorage(); // Persist queue + _saveQueueToStorage(); if (!state.isProcessing) { Future.microtask(() => _processQueue()); @@ -951,15 +937,14 @@ class DownloadQueueNotifier extends Notifier { .toList(); state = state.copyWith(items: items); - _saveQueueToStorage(); // Persist queue + _saveQueueToStorage(); } void clearAll() { state = state.copyWith(items: [], isPaused: false); - _saveQueueToStorage(); // Clear persisted queue + _saveQueueToStorage(); } - /// Pause the download queue void pauseQueue() { if (state.isProcessing && !state.isPaused) { state = state.copyWith(isPaused: true); @@ -968,7 +953,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Resume the download queue void resumeQueue() { if (state.isPaused) { state = state.copyWith(isPaused: false); @@ -979,7 +963,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Toggle pause/resume void togglePause() { if (state.isPaused) { resumeQueue(); @@ -988,7 +971,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Retry a failed or skipped download void retryItem(String id) { final item = state.items.where((i) => i.id == id).firstOrNull; if (item == null) { @@ -1025,14 +1007,12 @@ class DownloadQueueNotifier extends Notifier { } } - /// Remove a specific item from queue void removeItem(String id) { final items = state.items.where((item) => item.id != id).toList(); state = state.copyWith(items: items); - _saveQueueToStorage(); // Persist queue + _saveQueueToStorage(); } - /// Run post-processing hooks on a downloaded file Future _runPostProcessingHooks(String filePath, Track track) async { try { final settings = ref.read(settingsProvider); @@ -1079,7 +1059,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Upgrade Spotify cover URL to max quality (~2000x2000) /// Same logic as Go backend cover.go String _upgradeToMaxQualityCover(String coverUrl) { const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small) @@ -1098,7 +1077,6 @@ class DownloadQueueNotifier extends Notifier { return result; } - /// Embed metadata and cover to a FLAC file after M4A conversion Future _embedMetadataAndCover( String flacPath, Track track, { @@ -1155,12 +1133,12 @@ class DownloadQueueNotifier extends Notifier { if (track.trackNumber != null) { metadata['TRACKNUMBER'] = track.trackNumber.toString(); - metadata['TRACK'] = track.trackNumber.toString(); // Compatibility + metadata['TRACK'] = track.trackNumber.toString(); } if (track.discNumber != null) { metadata['DISCNUMBER'] = track.discNumber.toString(); - metadata['DISC'] = track.discNumber.toString(); // Compatibility + metadata['DISC'] = track.discNumber.toString(); } if (track.releaseDate != null) { @@ -1172,7 +1150,6 @@ class DownloadQueueNotifier extends Notifier { metadata['ISRC'] = track.isrc!; } - // Extended metadata from enrichment (genre, label, copyright) if (genre != null && genre.isNotEmpty) { metadata['GENRE'] = genre; _log.d('Adding GENRE: $genre'); @@ -1189,20 +1166,19 @@ class DownloadQueueNotifier extends Notifier { _log.d('Metadata map content: $metadata'); try { - // Convert duration from seconds to milliseconds for better lyrics matching final durationMs = track.duration * 1000; final lrcContent = await PlatformBridge.getLyricsLRC( - track.id, // spotifyID + track.id, track.name, track.artistName, - filePath: '', // No local file path yet (processed in memory) + filePath: '', durationMs: durationMs, ); if (lrcContent.isNotEmpty) { metadata['LYRICS'] = lrcContent; - metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players + metadata['UNSYNCEDLYRICS'] = lrcContent; _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); } } catch (e) { @@ -1240,7 +1216,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Embed metadata, lyrics, and cover to a MP3 file Future _embedMetadataToMp3(String mp3Path, Track track) async { final settings = ref.read(settingsProvider); @@ -1310,7 +1285,6 @@ class DownloadQueueNotifier extends Notifier { _log.d('MP3 Metadata map content: $metadata'); - // Fetch lyrics if embedLyrics is enabled if (settings.embedLyrics) { try { final durationMs = track.duration * 1000; @@ -1365,7 +1339,7 @@ class DownloadQueueNotifier extends Notifier { } Future _processQueue() async { - if (state.isProcessing) return; // Prevent multiple concurrent processing + if (state.isProcessing) return; state = state.copyWith(isProcessing: true); _log.i('Starting queue processing...'); @@ -1462,7 +1436,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Sequential download processing (uses multi-progress system with single item) Future _processQueueSequential() async { _startMultiProgressPolling(); @@ -1508,10 +1481,10 @@ class DownloadQueueNotifier extends Notifier { _stopProgressPolling(); } - /// Parallel download processing with worker pool Future _processQueueParallel() async { final maxConcurrent = state.concurrentDownloads; - final activeDownloads = >{}; // Map item ID to future + final activeDownloads = >{}; + _startMultiProgressPolling(); @@ -1565,7 +1538,6 @@ class DownloadQueueNotifier extends Notifier { _stopProgressPolling(); } - /// Download a single item (used by both sequential and parallel processing) Future _downloadSingleItem(DownloadItem item) async { _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); @@ -1628,7 +1600,6 @@ class DownloadQueueNotifier extends Notifier { trackToDownload.albumName, albumArtist: data['album_artist'] as String?, coverUrl: data['images'] as String?, - // duration_ms from Go is in milliseconds, Track.duration is in seconds duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ @@ -1675,7 +1646,6 @@ class DownloadQueueNotifier extends Notifier { String? genre; String? label; - // Try to get Deezer track ID from various sources String? deezerTrackId = trackToDownload.deezerId; if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { deezerTrackId = trackToDownload.id.split(':')[1]; @@ -1696,7 +1666,6 @@ class DownloadQueueNotifier extends Notifier { } } catch (e) { _log.w('Failed to fetch extended metadata from Deezer: $e'); - // Continue without extended metadata } } @@ -1728,7 +1697,7 @@ class DownloadQueueNotifier extends Notifier { releaseDate: trackToDownload.releaseDate, itemId: item.id, durationMs: trackToDownload.duration, - source: trackToDownload.source, // Pass extension ID that provided this track + source: trackToDownload.source, genre: genre, label: label, lyricsMode: settings.lyricsMode, @@ -1754,9 +1723,8 @@ class DownloadQueueNotifier extends Notifier { discNumber: trackToDownload.discNumber ?? 1, releaseDate: trackToDownload.releaseDate, preferredService: item.service, - itemId: item.id, // Pass item ID for progress tracking - durationMs: - trackToDownload.duration, // Duration in ms for verification + itemId: item.id, + durationMs: trackToDownload.duration, genre: genre, label: label, lyricsMode: settings.lyricsMode, @@ -1809,8 +1777,6 @@ class DownloadQueueNotifier extends Notifier { if (result['success'] == true) { var filePath = result['file_path'] as String?; - // Track if this was an existing file (not a new download) - // This is important to prevent converting existing FLAC files to MP3 final wasExisting = filePath != null && filePath.startsWith('EXISTS:'); if (wasExisting) { filePath = filePath.substring(7); // Remove "EXISTS:" prefix @@ -1912,7 +1878,6 @@ class DownloadQueueNotifier extends Notifier { ); } - // Get extended metadata from backend response final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; @@ -1962,15 +1927,9 @@ class DownloadQueueNotifier extends Notifier { return; } - // Convert FLAC to MP3 if MP3 quality was selected - // IMPORTANT: Only convert NEW downloads, never convert existing files - // to prevent overwriting the user's existing FLAC files if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) { if (wasExisting) { - // User wanted MP3 but an existing FLAC file was found - // Do NOT convert it - that would delete their existing FLAC _log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file'); - // Keep the existing FLAC file as-is } else { _log.i('MP3 quality selected, converting FLAC to MP3...'); updateItemStatus( @@ -1991,7 +1950,6 @@ class DownloadQueueNotifier extends Notifier { actualQuality = 'MP3 320kbps'; _log.i('Successfully converted to MP3: $mp3Path'); - // Embed metadata, lyrics, and cover to the MP3 file _log.i('Embedding metadata to MP3...'); updateItemStatus( item.id, @@ -2050,7 +2008,6 @@ class DownloadQueueNotifier extends Notifier { ? normalizedAlbumArtist : null; - // For MP3 files, don't save FLAC bitDepth/sampleRate - they're not applicable final isMp3 = filePath.endsWith('.mp3'); final historyBitDepth = isMp3 ? null : backendBitDepth; final historySampleRate = isMp3 ? null : backendSampleRate; diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 3eb6f444..6f74d25b 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; final _log = AppLogger('ExtensionProvider'); -/// Represents an installed extension class Extension { final String id; final String name; @@ -14,19 +13,19 @@ class Extension { final String author; final String description; final bool enabled; - final String status; // 'loaded', 'error', 'disabled' + final String status; final String? errorMessage; - final String? iconPath; // Path to extension icon + final String? iconPath; final List permissions; final List settings; - final List qualityOptions; // Custom quality options for download providers + final List qualityOptions; final bool hasMetadataProvider; final bool hasDownloadProvider; final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching - final SearchBehavior? searchBehavior; // Custom search behavior - final URLHandler? urlHandler; // Custom URL handling - final TrackMatching? trackMatching; // Custom track matching - final PostProcessing? postProcessing; // Post-processing hooks + final SearchBehavior? searchBehavior; + final URLHandler? urlHandler; + final TrackMatching? trackMatching; + final PostProcessing? postProcessing; const Extension({ required this.id, @@ -140,7 +139,6 @@ class Extension { bool get hasPostProcessing => postProcessing?.enabled ?? false; } -/// Custom search behavior configuration class SearchBehavior { final bool enabled; final String? placeholder; @@ -172,8 +170,6 @@ class SearchBehavior { ); } - /// Get thumbnail size based on configuration - /// Returns (width, height) tuple (double, double) getThumbnailSize({double defaultSize = 56}) { if (thumbnailWidth != null && thumbnailHeight != null) { return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); @@ -191,11 +187,10 @@ class SearchBehavior { } } -/// Custom track matching configuration class TrackMatching { final bool customMatching; - final String? strategy; // "isrc", "name", "duration", "custom" - final int durationTolerance; // in seconds + final String? strategy; + final int durationTolerance; const TrackMatching({ required this.customMatching, @@ -212,7 +207,6 @@ class TrackMatching { } } -/// Post-processing configuration class PostProcessing { final bool enabled; final List hooks; @@ -262,7 +256,6 @@ class URLHandler { } } -/// A post-processing hook class PostProcessingHook { final String id; final String name; @@ -289,12 +282,11 @@ class PostProcessingHook { } } -/// Represents a quality option for download providers class QualityOption { final String id; final String label; final String? description; - final List settings; // Quality-specific settings + final List settings; const QualityOption({ required this.id, @@ -315,14 +307,13 @@ class QualityOption { } } -/// Represents a setting that's specific to a quality option class QualitySpecificSetting { final String key; final String label; - final String type; // 'string', 'number', 'boolean', 'select' + final String type; final dynamic defaultValue; final String? description; - final List? options; // For select type + final List? options; final bool required; final bool secret; @@ -351,16 +342,15 @@ class QualitySpecificSetting { } } -/// Represents a setting field for an extension class ExtensionSetting { final String key; final String label; - final String type; // 'string', 'number', 'boolean', 'select', 'button' + final String type; final dynamic defaultValue; final String? description; - final List? options; // For select type + final List? options; final bool required; - final String? action; // For button type: JS function name to call + final String? action; const ExtensionSetting({ required this.key, @@ -387,7 +377,6 @@ class ExtensionSetting { } } -/// State for extension management class ExtensionState { final List extensions; final List providerPriority; @@ -425,7 +414,6 @@ class ExtensionState { } -/// Provider for managing extensions class ExtensionNotifier extends Notifier { @override ExtensionState build() { @@ -451,7 +439,6 @@ class ExtensionNotifier extends Notifier { } } - /// Load all extensions from directory Future loadExtensions(String dirPath) async { state = state.copyWith(isLoading: true, error: null); @@ -486,12 +473,10 @@ class ExtensionNotifier extends Notifier { } } - /// Clear any error state void clearError() { state = state.copyWith(error: null); } - /// Install extension from file (auto-upgrades if already installed with newer version) Future installExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); @@ -508,8 +493,6 @@ class ExtensionNotifier extends Notifier { } } - /// Check if a package file is an upgrade for an existing extension - /// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed} Future> checkExtensionUpgrade(String filePath) async { try { return await PlatformBridge.checkExtensionUpgrade(filePath); @@ -519,7 +502,6 @@ class ExtensionNotifier extends Notifier { } } - /// Upgrade an existing extension from a new package file Future upgradeExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); @@ -553,7 +535,6 @@ class ExtensionNotifier extends Notifier { } } - /// Enable or disable an extension Future setExtensionEnabled(String extensionId, bool enabled) async { try { await PlatformBridge.setExtensionEnabled(extensionId, enabled); @@ -600,7 +581,6 @@ class ExtensionNotifier extends Notifier { } } - /// Update settings for an extension Future setExtensionSettings(String extensionId, Map settings) async { try { await PlatformBridge.setExtensionSettings(extensionId, settings); @@ -621,7 +601,6 @@ class ExtensionNotifier extends Notifier { } } - /// Set provider priority order Future setProviderPriority(List priority) async { try { await PlatformBridge.setProviderPriority(priority); @@ -643,7 +622,6 @@ class ExtensionNotifier extends Notifier { } } - /// Set metadata provider priority order Future setMetadataProviderPriority(List priority) async { try { await PlatformBridge.setMetadataProviderPriority(priority); @@ -665,7 +643,6 @@ class ExtensionNotifier extends Notifier { } } - /// Get extension by ID Extension? getExtension(String extensionId) { try { return state.extensions.firstWhere((ext) => ext.id == extensionId); @@ -679,7 +656,6 @@ class ExtensionNotifier extends Notifier { return state.extensions.where((ext) => ext.enabled).toList(); } - /// Get all download providers (built-in + extensions) List getAllDownloadProviders() { final providers = ['tidal', 'qobuz', 'amazon']; for (final ext in state.extensions) { @@ -700,7 +676,6 @@ class ExtensionNotifier extends Notifier { } return providers; } - /// Get all extensions that provide custom search List get searchProviders { return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); } diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index 1671eda4..ab0b1466 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -121,7 +121,6 @@ class RecentAccessNotifier extends Notifier { .map((e) => RecentAccessItem.fromJson(e as Map)) .toList(); } catch (e) { - // Ignore parse errors } } @@ -266,7 +265,6 @@ class RecentAccessNotifier extends Notifier { } } -/// Provider instance final recentAccessProvider = NotifierProvider( RecentAccessNotifier.new, ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 2de3d839..1e7830f4 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -30,7 +30,6 @@ class SettingsNotifier extends Notifier { } } - /// Run one-time migrations for settings Future _runMigrations(SharedPreferences prefs) async { final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; @@ -51,7 +50,6 @@ class SettingsNotifier extends Notifier { await prefs.setString(_settingsKey, jsonEncode(state.toJson())); } - /// Apply current Spotify credentials to Go backend Future _applySpotifyCredentials() async { if (state.spotifyClientId.isNotEmpty && state.spotifyClientSecret.isNotEmpty) { @@ -93,7 +91,6 @@ class SettingsNotifier extends Notifier { } void setLyricsMode(String mode) { - // Valid modes: embed, external, both if (mode == 'embed' || mode == 'external' || mode == 'both') { state = state.copyWith(lyricsMode: mode); _saveSettings(); diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index fe067e5e..6a314cab 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -52,7 +52,6 @@ class StoreCategory { } } -/// Represents an extension in the store class StoreExtension { final String id; final String name; @@ -118,7 +117,6 @@ class StoreExtension { } } -/// State for extension store class StoreState { final List extensions; final String? selectedCategory; @@ -200,7 +198,6 @@ class StoreNotifier extends Notifier { return const StoreState(); } - /// Initialize the store Future initialize(String cacheDir) async { if (state.isInitialized) return; @@ -234,7 +231,6 @@ class StoreNotifier extends Notifier { } } - /// Set category filter void setCategory(String? category) { if (category == null) { state = state.copyWith(clearCategory: true); @@ -248,7 +244,6 @@ class StoreNotifier extends Notifier { state = state.copyWith(searchQuery: query); } - /// Clear search void clearSearch() { state = state.copyWith(searchQuery: '', clearCategory: true); } @@ -279,7 +274,6 @@ class StoreNotifier extends Notifier { } } - /// Update an installed extension Future updateExtension(String extensionId, String tempDir) async { state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); @@ -305,7 +299,6 @@ class StoreNotifier extends Notifier { } } - /// Clear error void clearError() { state = state.copyWith(clearError: true); } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index ca40c032..f1a3e728 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -34,7 +34,6 @@ class ThemeNotifier extends Notifier { ); } catch (e) { debugPrint('Error loading theme settings: $e'); - // Keep default state on error } } diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index cb0fad0b..074fede8 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -89,7 +89,6 @@ class TrackState { } } -/// Represents an album in artist discography class ArtistAlbum { final String id; final String name; @@ -112,7 +111,6 @@ class ArtistAlbum { }); } -/// Represents an artist in search results class SearchArtist { final String id; final String name; @@ -130,7 +128,6 @@ class SearchArtist { } class TrackNotifier extends Notifier { - /// Request ID to track and cancel outdated requests int _currentRequestId = 0; @override @@ -213,14 +210,8 @@ class TrackNotifier extends Notifier { Map metadata; try { - // ignore: avoid_print - print('[FetchURL] Fetching $type with Deezer fallback enabled...'); metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); - // ignore: avoid_print - print('[FetchURL] Metadata fetch success'); } catch (e) { - // ignore: avoid_print - print('[FetchURL] Metadata fetch failed: $e'); rethrow; } @@ -263,7 +254,7 @@ class TrackNotifier extends Notifier { final albumsList = metadata['albums'] as List; final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); state = TrackState( - tracks: [], // No tracks for artist view + tracks: [], isLoading: false, artistId: artistInfo['id'] as String?, artistName: artistInfo['name'] as String?, @@ -397,7 +388,6 @@ class TrackNotifier extends Notifier { } } - /// Perform custom search using a specific extension Future customSearch(String extensionId, String query, {Map? options}) async { final requestId = ++_currentRequestId; @@ -429,7 +419,7 @@ class TrackNotifier extends Notifier { state = TrackState( tracks: tracks, - searchArtists: [], // Custom search doesn't return artists + searchArtists: [], isLoading: false, hasSearchText: state.hasSearchText, searchExtensionId: extensionId, // Store which extension was used @@ -477,8 +467,6 @@ class TrackNotifier extends Notifier { tracks[index] = updatedTrack; state = state.copyWith(tracks: tracks); } catch (e) { - // Silently ignore availability check errors - // This is a background operation that shouldn't disrupt the user } } @@ -494,7 +482,6 @@ class TrackNotifier extends Notifier { state = state.copyWith(hasSearchText: hasText); } - /// Set recent access mode state void setShowingRecentAccess(bool showing) { state = state.copyWith(isShowingRecentAccess: showing); } @@ -584,8 +571,6 @@ class TrackNotifier extends Notifier { ); } - /// Pre-warm track ID cache for faster downloads - /// Runs in background, doesn't block UI void _preWarmCacheForTracks(List tracks) { final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); if (tracksWithIsrc.isEmpty) return; diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 5eede561..86db03de 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -13,7 +13,6 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; -/// Simple in-memory cache for album tracks class _AlbumCache { static final Map _cache = {}; static const Duration _ttl = Duration(minutes: 10); @@ -39,7 +38,6 @@ class _CacheEntry { _CacheEntry(this.tracks, this.expiresAt); } -/// Album detail screen with Material Expressive 3 design class AlbumScreen extends ConsumerStatefulWidget { final String albumId; final String albumName; @@ -99,7 +97,6 @@ class _AlbumScreenState extends ConsumerState { } void _onScroll() { - // Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top) final shouldShow = _scrollController.offset > 280; if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); @@ -121,7 +118,6 @@ class _AlbumScreenState extends ConsumerState { }); } } catch (_) { - // Ignore palette extraction errors } } @@ -132,12 +128,8 @@ class _AlbumScreenState extends ConsumerState { if (widget.albumId.startsWith('deezer:')) { final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); - // ignore: avoid_print - print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId'); metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId); } else { - // ignore: avoid_print - print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}'); final url = 'https://open.spotify.com/album/${widget.albumId}'; metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); } @@ -219,7 +211,7 @@ class _AlbumScreenState extends ConsumerState { expandedHeight: 320, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, // Use theme color for collapsed state + backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -261,7 +253,6 @@ class _AlbumScreenState extends ConsumerState { ), ), ), - // Cover image centered - fade out when collapsing AnimatedOpacity( duration: const Duration(milliseconds: 150), opacity: showContent ? 1.0 : 0.0, @@ -449,7 +440,6 @@ class _AlbumScreenState extends ConsumerState { } } - /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -512,7 +502,6 @@ class _AlbumScreenState extends ConsumerState { } } -/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes class _AlbumTrackItem extends ConsumerWidget { final Track track; final VoidCallback onDownload; diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 7a7a35a4..5dda70bf 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -97,7 +97,6 @@ class _ArtistScreenState extends ConsumerState { int? _monthlyListeners; String? _error; - // Sticky title state bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); @@ -310,7 +309,6 @@ return Scaffold( ); } - /// Build Spotify-style header with full-width image and artist name overlay Widget _buildHeader(BuildContext context, ColorScheme colorScheme) { String? imageUrl = _headerImageUrl; if (imageUrl == null || imageUrl.isEmpty) { @@ -479,7 +477,6 @@ if (hasValidImage) ); } - /// Build a single popular track item with dynamic download status Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { final queueItem = ref.watch( downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), @@ -608,7 +605,6 @@ if (hasValidImage) _downloadTrack(track); } - /// Build download button with status indicator for popular tracks Widget _buildPopularDownloadButton({ required Track track, required ColorScheme colorScheme, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 830dd8c3..6cc2f621 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -77,7 +77,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { }); } } catch (_) { - // Ignore palette extraction errors } } @@ -508,9 +507,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { final discMap = _groupTracksByDisc(tracks); - // Single disc - use simple list if (discMap.length <= 1) { - // Single disc - use simple list return SliverList( delegate: SliverChildBuilderDelegate( (context, index) { @@ -525,7 +522,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } - // Multiple discs - build list with separators final discNumbers = discMap.keys.toList()..sort(); final List children = []; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index c692f6db..7339bbba 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -75,7 +75,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - /// Called when trackState changes - used to sync search bar with state void _onTrackStateChanged(TrackState? previous, TrackState next) { if (previous != null && !next.hasContent && @@ -96,7 +95,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (searchProvider == null || searchProvider.isEmpty) return false; - // Check if the extension is enabled and has search capability final extension = extState.extensions.where((e) => e.id == searchProvider && e.enabled).firstOrNull; return extension != null; } @@ -130,10 +128,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - /// Execute live search with concurrency protection - /// Prevents race conditions in extensions by ensuring only one search runs at a time Future _executeLiveSearch(String query) async { - // If a search is already in progress, queue this one if (_isLiveSearchInProgress) { _pendingLiveSearchQuery = query; return; @@ -151,13 +146,10 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final pending = _pendingLiveSearchQuery; _pendingLiveSearchQuery = null; - // Execute pending query if it's different from what we just searched - // and still matches current text field content if (pending != null && pending != query && mounted && _urlController.text.trim() == pending) { - // Small delay to let extension's state settle await Future.delayed(const Duration(milliseconds: 100)); if (mounted && _urlController.text.trim() == pending) { _executeLiveSearch(pending); @@ -224,7 +216,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); } - /// Navigate to detail screen based on fetched content type void _navigateToDetailIfNeeded() { final trackState = ref.read(trackProvider); @@ -356,7 +347,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // ignore: use_build_context_synchronously final l10n = context.l10n; - // Show quality picker if enabled in settings if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( this.context, @@ -676,7 +666,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - /// Build recent access history section (shown when search focused) Widget _buildRecentAccess(List items, ColorScheme colorScheme) { final historyItems = ref.read(downloadHistoryProvider).items; @@ -690,9 +679,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient albumGroups.putIfAbsent(albumKey, () => []).add(h); } - // Convert to RecentAccessItem based on track count: - // - 1 track: show as individual Track - // - 2+ tracks: show as Album final downloadItems = []; for (final entry in albumGroups.entries) { final tracks = entry.value; @@ -703,7 +689,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient : mostRecent.artistName; if (tracks.length == 1) { - // Single track - show as Track downloadItems.add(RecentAccessItem( id: mostRecent.spotifyId ?? mostRecent.id, name: mostRecent.trackName, @@ -714,7 +699,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient providerId: 'download', )); } else { - // Multiple tracks - show as Album downloadItems.add(RecentAccessItem( id: '${mostRecent.albumName}|$artistForKey', name: mostRecent.albumName, @@ -727,10 +711,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - // Sort by most recent and take top 10 downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - - // Filter out hidden downloads (use ref.watch for reactivity) + final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds)); final visibleDownloads = downloadItems .where((item) => !hiddenIds.contains(item.id)) @@ -768,11 +750,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (uniqueItems.isNotEmpty) TextButton( onPressed: () { - // Hide ALL download items (not just visible ones) for (final item in downloadItems) { ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); } - // Clear non-download recent history ref.read(recentAccessProvider.notifier).clearHistory(); }, child: Text( @@ -784,7 +764,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), const SizedBox(height: 8), if (uniqueItems.isEmpty && hasHiddenDownloads) - // Show "Show All" button when recents is empty but there are hidden downloads Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), @@ -897,10 +876,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant), onPressed: () { if (item.providerId == 'download') { - // For download items, hide from recents without deleting the file ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); } else { - // For other items, remove from recent history ref.read(recentAccessProvider.notifier).removeItem(item); } }, @@ -936,7 +913,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } case RecentAccessType.album: - // Handle downloaded albums - navigate to DownloadedAlbumScreen if (item.providerId == 'download') { Navigator.push(context, MaterialPageRoute( builder: (context) => DownloadedAlbumScreen( @@ -1000,7 +976,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -1427,7 +1402,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } - /// Get search hint based on selected provider String _getSearchHint() { final settings = ref.read(settingsProvider); final searchProvider = settings.searchProvider; @@ -1474,11 +1448,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), prefixIcon: _SearchProviderDropdown( onProviderChanged: () { - // Reset search state when provider changes _lastSearchQuery = null; - // Force rebuild to update hint text setState(() {}); - // Re-trigger search if there's text final text = _urlController.text.trim(); if (text.isNotEmpty && text.length >= _minLiveSearchChars) { _performSearch(text); @@ -1514,9 +1485,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - /// Handle Enter key press - search or fetch URL void _onSearchSubmitted() { - // Cancel any pending live search since user explicitly pressed enter _liveSearchDebounce?.cancel(); _pendingLiveSearchQuery = null; @@ -1549,13 +1518,11 @@ class _SearchProviderDropdown extends ConsumerWidget { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - // Get current provider info final currentProvider = settings.searchProvider; final searchProviders = extState.extensions .where((ext) => ext.enabled && ext.hasCustomSearch) .toList(); - // Find current provider extension Extension? currentExt; if (currentProvider != null && currentProvider.isNotEmpty) { currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull; @@ -1567,12 +1534,10 @@ class _SearchProviderDropdown extends ConsumerWidget { if (currentExt != null) { iconPath = currentExt.iconPath; if (currentExt.searchBehavior?.icon != null) { - // Use search behavior icon if available displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); } } - // Don't show dropdown if no custom search providers available if (searchProviders.isEmpty) { return const Icon(Icons.search); } @@ -1608,15 +1573,13 @@ class _SearchProviderDropdown extends ConsumerWidget { offset: const Offset(0, 40), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), onSelected: (String providerId) { - // Empty string means default (Deezer/Spotify) final provider = providerId.isEmpty ? null : providerId; ref.read(settingsProvider.notifier).setSearchProvider(provider); onProviderChanged?.call(); }, itemBuilder: (context) => [ - // Default option (Deezer/Spotify based on metadata source) PopupMenuItem( - value: '', // Empty string = default provider + value: '', child: Row( children: [ Icon( @@ -1716,7 +1679,6 @@ class _SearchProviderDropdown extends ConsumerWidget { } } -/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes class _TrackItemWithStatus extends ConsumerWidget { final Track track; final int index; @@ -2028,7 +1990,6 @@ class _CollectionItemWidget extends StatelessWidget { } } -/// Screen for viewing extension album with track fetching class ExtensionAlbumScreen extends ConsumerStatefulWidget { final String extensionId; final String albumId; @@ -2299,7 +2260,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState { } } - /// Handle back press with double-tap to exit void _handleBackPress() { final trackState = ref.read(trackProvider); @@ -174,9 +173,6 @@ class _MainShellState extends ConsumerState { final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; - // Determine if we can pop (for predictive back animation) - // canPop is true when we're at root with no content - enables predictive back gesture - // IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation final canPop = _currentIndex == 0 && !trackState.hasSearchText && !trackState.hasContent && @@ -250,8 +246,6 @@ class _MainShellState extends ConsumerState { canPop: canPop, onPopInvokedWithResult: (didPop, result) async { if (didPop) { - // System handled the pop - this means predictive back completed - // We need to handle double-tap to exit here return; } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index a62af731..e64c8daf 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -11,7 +11,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; -/// Playlist detail screen with Material Expressive 3 design class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; final String? coverUrl; @@ -69,7 +68,6 @@ class _PlaylistScreenState extends ConsumerState { }); } } catch (_) { - // Ignore palette extraction errors } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 1017cc29..c8bd4f7e 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -13,7 +13,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; -/// Grouped album data for history display class _GroupedAlbum { final String albumName; final String artistName; @@ -108,7 +107,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Enter selection mode with initial item void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); setState(() { @@ -125,7 +123,6 @@ class _QueueTabState extends ConsumerState { }); } - /// Toggle item selection void _toggleSelection(String itemId) { setState(() { if (_selectedIds.contains(itemId)) { @@ -146,7 +143,6 @@ class _QueueTabState extends ConsumerState { }); } - /// Delete selected items Future _deleteSelected() async { final count = _selectedIds.length; final confirmed = await showDialog( @@ -307,9 +303,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Filter history items based on current filter mode - /// Album = track yang albumnya punya >1 track di history - /// Single = track yang albumnya cuma 1 track di history List _filterHistoryItems( List items, String filterMode, @@ -725,7 +718,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Build content for each filter tab Widget _buildFilterContent({ required BuildContext context, required ColorScheme colorScheme, @@ -931,7 +923,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Build album grid item for grouped albums view Widget _buildAlbumGridItem( BuildContext context, _GroupedAlbum album, @@ -1745,7 +1736,6 @@ child: CachedNetworkImage( } } -/// Filter chip widget for history filtering class _FilterChip extends StatelessWidget { final String label; final int count; diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 3df2dd2c..a68d1f03 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -15,7 +15,7 @@ class AboutPage extends StatelessWidget { final topPadding = MediaQuery.of(context).padding.top; return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ @@ -253,9 +253,9 @@ class _AppHeaderCard extends StatelessWidget { color: colorScheme.primary, shape: BoxShape.circle, ), - child: Image.asset( + child: Image.asset( 'assets/images/logo-transparant.png', - color: colorScheme.onPrimary, // Tint with onPrimary color + color: colorScheme.onPrimary, fit: BoxFit.contain, errorBuilder: (_, _, _) => ClipRRect( borderRadius: BorderRadius.circular(24), diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 4e039a85..92ca5ff0 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -17,7 +17,7 @@ class AppearanceSettingsPage extends ConsumerWidget { final topPadding = MediaQuery.of(context).padding.top; return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ @@ -161,7 +161,7 @@ class _ThemePreviewCard extends StatelessWidget { width: double.infinity, decoration: BoxDecoration( color: colorScheme - .surfaceContainerHighest, // Background similar to reference + .surfaceContainerHighest, borderRadius: BorderRadius.circular(28), ), clipBehavior: Clip.antiAlias, @@ -203,7 +203,7 @@ class _ThemePreviewCard extends StatelessWidget { boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), - blurRadius: 12, // Reduced from 20 for performance + blurRadius: 12, offset: const Offset(0, 8), ), ], diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 14b782a2..f408c64d 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerWidget { final isBuiltInService = _builtInServices.contains(settings.defaultService); return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 3c603de6..7c9036db 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -581,7 +581,7 @@ class _SetupScreenState extends ConsumerState { switch (step) { case 0: return _storagePermissionGranted; case 1: return _selectedDirectory != null; - case 2: return false; // Spotify step never shows checkmark (optional) + case 2: return false; } } return false; diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 9053f546..2114777f 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -122,7 +122,7 @@ class _StoreTabState extends ConsumerState { ), onChanged: (value) { ref.read(storeProvider.notifier).setSearchQuery(value); - setState(() {}); // Update suffix icon + setState(() {}); }, ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 4e236f0c..caaa6343 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -13,8 +13,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -/// Screen to display detailed metadata for a downloaded track -/// Designed with Material Expressive 3 style class TrackMetadataScreen extends ConsumerStatefulWidget { final DownloadHistoryItem item; @@ -101,7 +99,6 @@ class _TrackMetadataScreenState extends ConsumerState { }); } } catch (_) { - // Ignore palette extraction errors } } @@ -263,7 +260,6 @@ class _TrackMetadataScreenState extends ConsumerState { return Stack( fit: StackFit.expand, children: [ - // Background with dominant color AnimatedContainer( duration: const Duration(milliseconds: 500), decoration: BoxDecoration( @@ -280,7 +276,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), - // Cover image centered - fade out when collapsing AnimatedOpacity( duration: const Duration(milliseconds: 150), opacity: showContent ? 1.0 : 0.0, @@ -683,7 +678,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - // Show 320kbps for MP3, bit depth/sample rate for FLAC if (fileExtension == 'MP3') Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), @@ -1057,8 +1051,8 @@ class _TrackMetadataScreenState extends ConsumerState { ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); if (context.mounted) { - Navigator.pop(context); // Close dialog - Navigator.pop(context); // Go back to history + Navigator.pop(context); + Navigator.pop(context); } }, child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)), diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index 37d2fc3f..288dad2f 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -18,8 +18,6 @@ class CoverCacheManager { static bool _initialized = false; static String? _cachePath; - /// Get the singleton cache manager instance. - /// Must call [initialize] before using this. static CacheManager get instance { if (!_initialized || _instance == null) { // Fallback to default cache manager if not initialized @@ -32,8 +30,6 @@ class CoverCacheManager { /// Check if cache manager is initialized static bool get isInitialized => _initialized && _instance != null; - /// Initialize the cache manager with persistent storage path. - /// Call this once during app startup (in main.dart). static Future initialize() async { if (_initialized) return; @@ -73,7 +69,6 @@ class CoverCacheManager { await _instance!.emptyCache(); } - /// Get cache statistics static Future getStats() async { if (!_initialized || _cachePath == null) { return const CacheStats(fileCount: 0, totalSizeBytes: 0); @@ -113,7 +108,6 @@ class CacheStats { required this.totalSizeBytes, }); - /// Get human-readable size string String get formattedSize { if (totalSizeBytes < 1024) { return '$totalSizeBytes B'; diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index b2ef0a1c..0687f385 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -7,8 +7,6 @@ import 'package:spotiflac_android/utils/logger.dart'; class CsvImportService { static final _log = AppLogger('CsvImportService'); - /// Pick and parse CSV file, then enrich metadata from Deezer - /// [onProgress] callback receives (current, total) for progress updates static Future> pickAndParseCsv({ void Function(int current, int total)? onProgress, }) async { @@ -34,8 +32,6 @@ class CsvImportService { return []; } - /// Enrich tracks with metadata from Deezer using ISRC or search - /// This fetches cover URL, duration, and other metadata that CSV doesn't have static Future> _enrichTracksMetadata( List tracks, { void Function(int current, int total)? onProgress, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 3ed2ced7..6243d41e 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -10,7 +10,6 @@ final _log = AppLogger('FFmpeg'); class FFmpegService { static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg'); - /// Execute FFmpeg command and return result static Future _execute(String command) async { try { final result = await _channel.invokeMethod('execute', {'command': command}); @@ -26,8 +25,6 @@ class FFmpegService { } } - /// Convert M4A (DASH segments) to FLAC - /// Returns the output file path on success, null on failure static Future convertM4aToFlac(String inputPath) async { final outputPath = inputPath.replaceAll('.m4a', '.flac'); @@ -47,14 +44,11 @@ class FFmpegService { return null; } - /// Convert FLAC to MP3 - /// If deleteOriginal is true, deletes the FLAC file after conversion static Future convertFlacToMp3( String inputPath, { String bitrate = '320k', bool deleteOriginal = true, }) async { - // Convert in same folder, just change extension final outputPath = inputPath.replaceAll('.flac', '.mp3'); final command = @@ -63,7 +57,6 @@ class FFmpegService { final result = await _execute(command); if (result.success) { - // Delete original FLAC if requested if (deleteOriginal) { try { await File(inputPath).delete(); @@ -76,7 +69,6 @@ class FFmpegService { return null; } - /// Convert FLAC to M4A (AAC or ALAC) static Future convertFlacToM4a( String inputPath, { String codec = 'aac', @@ -110,7 +102,6 @@ class FFmpegService { return null; } - /// Check if FFmpeg is available static Future isAvailable() async { try { final version = await _channel.invokeMethod('getVersion'); @@ -120,7 +111,6 @@ class FFmpegService { } } - /// Get FFmpeg version info static Future getVersion() async { try { final version = await _channel.invokeMethod('getVersion'); @@ -130,8 +120,6 @@ class FFmpegService { } } - /// Embed metadata and cover art to FLAC file - /// Returns the file path on success, null on failure static Future embedMetadata({ required String flacPath, String? coverPath, @@ -211,8 +199,6 @@ class FFmpegService { return null; } - /// Embed metadata and cover art to MP3 file using ID3v2 tags - /// Returns the file path on success, null on failure static Future embedMetadataToMp3({ required String mp3Path, String? coverPath, @@ -242,7 +228,6 @@ class FFmpegService { cmdBuffer.write('-c:a copy '); if (metadata != null) { - // Convert FLAC/Vorbis tags to ID3v2 tags for MP3 final id3Metadata = _convertToId3Tags(metadata); id3Metadata.forEach((key, value) { final sanitizedValue = value.replaceAll('"', '\\"'); @@ -295,7 +280,6 @@ class FFmpegService { return null; } - /// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags static Map _convertToId3Tags(Map vorbisMetadata) { final id3Map = {}; @@ -330,7 +314,7 @@ class FFmpegService { id3Map['date'] = value; break; case 'ISRC': - id3Map['TSRC'] = value; // ID3v2 ISRC frame + id3Map['TSRC'] = value; break; case 'LYRICS': case 'UNSYNCEDLYRICS': @@ -346,7 +330,6 @@ class FFmpegService { } } -/// Result of FFmpeg command execution class FFmpegResult { final bool success; final int returnCode; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 8658a6d7..f0895bcd 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -4,25 +4,21 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('PlatformBridge'); -/// Bridge to communicate with Go backend via platform channels class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); - /// Parse and validate Spotify URL static Future> parseSpotifyUrl(String url) async { _log.d('parseSpotifyUrl: $url'); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); return jsonDecode(result as String) as Map; } - /// Get Spotify metadata from URL static Future> getSpotifyMetadata(String url) async { _log.d('getSpotifyMetadata: $url'); final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url}); return jsonDecode(result as String) as Map; } - /// Search Spotify static Future> searchSpotify(String query, {int limit = 10}) async { _log.d('searchSpotify: "$query" (limit: $limit)'); final result = await _channel.invokeMethod('searchSpotify', { @@ -32,7 +28,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Search Spotify for tracks and artists static Future> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { _log.d('searchSpotifyAll: "$query"'); final result = await _channel.invokeMethod('searchSpotifyAll', { @@ -43,7 +38,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Check track availability on streaming services static Future> checkAvailability(String spotifyId, String isrc) async { _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); final result = await _channel.invokeMethod('checkAvailability', { @@ -53,7 +47,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Download a track from specific service static Future> downloadTrack({ required String isrc, required String service, @@ -108,7 +101,6 @@ class PlatformBridge { return response; } - /// Download with automatic fallback to other services static Future> downloadWithFallback({ required String isrc, required String spotifyId, @@ -129,11 +121,9 @@ class PlatformBridge { String preferredService = 'tidal', String? itemId, int durationMs = 0, - // Extended metadata for FLAC tagging String? genre, String? label, String? copyright, - // Lyrics mode: "embed" (default), "external" (.lrc file), "both" String lyricsMode = 'embed', }) async { _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); @@ -157,11 +147,9 @@ class PlatformBridge { 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', 'duration_ms': durationMs, - // Extended metadata 'genre': genre ?? '', 'label': label ?? '', 'copyright': copyright ?? '', - // Lyrics mode 'lyrics_mode': lyricsMode, }); @@ -184,44 +172,36 @@ class PlatformBridge { return response; } - /// Get download progress (legacy single download) static Future> getDownloadProgress() async { final result = await _channel.invokeMethod('getDownloadProgress'); return jsonDecode(result as String) as Map; } - /// Get progress for all active downloads (concurrent mode) static Future> getAllDownloadProgress() async { final result = await _channel.invokeMethod('getAllDownloadProgress'); return jsonDecode(result as String) as Map; } - /// Initialize progress tracking for a download item static Future initItemProgress(String itemId) async { await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); } - /// Finish progress tracking for a download item static Future finishItemProgress(String itemId) async { await _channel.invokeMethod('finishItemProgress', {'item_id': itemId}); } - /// Clear progress tracking for a download item static Future clearItemProgress(String itemId) async { await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); } - /// Cancel an in-progress download static Future cancelDownload(String itemId) async { await _channel.invokeMethod('cancelDownload', {'item_id': itemId}); } - /// Set download directory static Future setDownloadDirectory(String path) async { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); } - /// Check if file with ISRC already exists static Future> checkDuplicate(String outputDir, String isrc) async { final result = await _channel.invokeMethod('checkDuplicate', { 'output_dir': outputDir, @@ -230,7 +210,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Build filename from template static Future buildFilename(String template, Map metadata) async { final result = await _channel.invokeMethod('buildFilename', { 'template': template, @@ -239,7 +218,6 @@ class PlatformBridge { return result as String; } - /// Sanitize filename static Future sanitizeFilename(String filename) async { final result = await _channel.invokeMethod('sanitizeFilename', { 'filename': filename, @@ -247,8 +225,6 @@ class PlatformBridge { return result as String; } - /// Fetch lyrics for a track - /// [durationMs] is the track duration in milliseconds for better matching static Future> fetchLyrics( String spotifyId, String trackName, @@ -264,9 +240,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get lyrics in LRC format - /// First tries to extract from embedded file, then falls back to internet - /// [durationMs] is the track duration in milliseconds for better matching static Future getLyricsLRC( String spotifyId, String trackName, @@ -284,7 +257,6 @@ class PlatformBridge { return result as String; } - /// Embed lyrics into an existing FLAC file static Future> embedLyricsToFile( String filePath, String lyrics, @@ -296,15 +268,10 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Cleanup idle HTTP connections to prevent TCP exhaustion - /// Call this periodically during large batch downloads static Future cleanupConnections() async { await _channel.invokeMethod('cleanupConnections'); } - /// Read metadata directly from a FLAC file - /// Returns all embedded metadata (title, artist, album, track number, etc.) - /// This reads from the actual file, not from cached/database data static Future> readFileMetadata(String filePath) async { final result = await _channel.invokeMethod('readFileMetadata', { 'file_path': filePath, @@ -312,7 +279,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Start foreground download service to keep downloads running in background static Future startDownloadService({ String trackName = '', String artistName = '', @@ -325,12 +291,10 @@ class PlatformBridge { }); } - /// Stop foreground download service static Future stopDownloadService() async { await _channel.invokeMethod('stopDownloadService'); } - /// Update download service notification progress static Future updateDownloadServiceProgress({ required String trackName, required String artistName, @@ -347,13 +311,11 @@ class PlatformBridge { }); } - /// Check if download service is running static Future isDownloadServiceRunning() async { final result = await _channel.invokeMethod('isDownloadServiceRunning'); return result as bool; } - /// Set custom Spotify API credentials static Future setSpotifyCredentials(String clientId, String clientSecret) async { await _channel.invokeMethod('setSpotifyCredentials', { 'client_id': clientId, @@ -361,35 +323,26 @@ class PlatformBridge { }); } - /// Check if Spotify credentials are configured /// Returns true if credentials are available (custom or env vars) static Future hasSpotifyCredentials() async { final result = await _channel.invokeMethod('hasSpotifyCredentials'); return result as bool; } - /// Pre-warm track ID cache for album/playlist tracks - /// This runs in background and returns immediately - /// Speeds up subsequent downloads by caching ISRC → Track ID mappings static Future preWarmTrackCache(List> tracks) async { final tracksJson = jsonEncode(tracks); await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson}); } - /// Get current track cache size static Future getTrackCacheSize() async { final result = await _channel.invokeMethod('getTrackCacheSize'); return result as int; } - /// Clear track ID cache static Future clearTrackCache() async { await _channel.invokeMethod('clearTrackCache'); } - // ==================== DEEZER API ==================== - - /// Search Deezer for tracks and artists (no API key required) static Future> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { final result = await _channel.invokeMethod('searchDeezerAll', { 'query': query, @@ -399,7 +352,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get Deezer metadata by type and ID static Future> getDeezerMetadata(String resourceType, String resourceId) async { final result = await _channel.invokeMethod('getDeezerMetadata', { 'resource_type': resourceType, @@ -411,20 +363,16 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Parse Deezer URL and return type and ID static Future> parseDeezerUrl(String url) async { final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); return jsonDecode(result as String) as Map; } - /// Search Deezer by ISRC static Future> searchDeezerByISRC(String isrc) async { final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc}); return jsonDecode(result as String) as Map; } - /// Get extended metadata (genre, label) from Deezer using track ID - /// Returns {"genre": "...", "label": "..."} or null if not found static Future?> getDeezerExtendedMetadata(String trackId) async { try { final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { @@ -442,7 +390,6 @@ class PlatformBridge { } } - /// Convert Spotify track to Deezer and get metadata (for rate limit fallback) static Future> convertSpotifyToDeezer(String resourceType, String spotifyId) async { final result = await _channel.invokeMethod('convertSpotifyToDeezer', { 'resource_type': resourceType, @@ -451,15 +398,11 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get Spotify metadata with automatic Deezer fallback on rate limit static Future> getSpotifyMetadataWithFallback(String url) async { final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url}); return jsonDecode(result as String) as Map; } - // ==================== GO BACKEND LOGS ==================== - - /// Get all logs from Go backend static Future>> getGoLogs() async { final result = await _channel.invokeMethod('getLogs'); final logs = jsonDecode(result as String) as List; @@ -472,25 +415,20 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Clear Go backend logs static Future clearGoLogs() async { await _channel.invokeMethod('clearLogs'); } - /// Get Go backend log count static Future getGoLogCount() async { final result = await _channel.invokeMethod('getLogCount'); return result as int; } - /// Enable or disable Go backend logging static Future setGoLoggingEnabled(bool enabled) async { await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); } - // ==================== EXTENSION SYSTEM ==================== - /// Initialize the extension system static Future initExtensionSystem(String extensionsDir, String dataDir) async { _log.d('initExtensionSystem: $extensionsDir, $dataDir'); await _channel.invokeMethod('initExtensionSystem', { @@ -499,7 +437,6 @@ class PlatformBridge { }); } - /// Load all extensions from directory static Future> loadExtensionsFromDir(String dirPath) async { _log.d('loadExtensionsFromDir: $dirPath'); final result = await _channel.invokeMethod('loadExtensionsFromDir', { @@ -508,7 +445,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Load a single extension from file static Future> loadExtensionFromPath(String filePath) async { _log.d('loadExtensionFromPath: $filePath'); final result = await _channel.invokeMethod('loadExtensionFromPath', { @@ -517,7 +453,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Unload an extension static Future unloadExtension(String extensionId) async { _log.d('unloadExtension: $extensionId'); await _channel.invokeMethod('unloadExtension', { @@ -525,7 +460,6 @@ class PlatformBridge { }); } - /// Remove an extension completely (unload + delete files) static Future removeExtension(String extensionId) async { _log.d('removeExtension: $extensionId'); await _channel.invokeMethod('removeExtension', { @@ -533,7 +467,6 @@ class PlatformBridge { }); } - /// Upgrade an existing extension from a new package file static Future> upgradeExtension(String filePath) async { _log.d('upgradeExtension: $filePath'); final result = await _channel.invokeMethod('upgradeExtension', { @@ -542,7 +475,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Check if a package file is an upgrade for an existing extension static Future> checkExtensionUpgrade(String filePath) async { _log.d('checkExtensionUpgrade: $filePath'); final result = await _channel.invokeMethod('checkExtensionUpgrade', { @@ -551,14 +483,12 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get all installed extensions static Future>> getInstalledExtensions() async { final result = await _channel.invokeMethod('getInstalledExtensions'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - /// Enable or disable an extension static Future setExtensionEnabled(String extensionId, bool enabled) async { _log.d('setExtensionEnabled: $extensionId = $enabled'); await _channel.invokeMethod('setExtensionEnabled', { @@ -567,7 +497,6 @@ class PlatformBridge { }); } - /// Set provider priority order static Future setProviderPriority(List providerIds) async { _log.d('setProviderPriority: $providerIds'); await _channel.invokeMethod('setProviderPriority', { @@ -575,14 +504,12 @@ class PlatformBridge { }); } - /// Get provider priority order static Future> getProviderPriority() async { final result = await _channel.invokeMethod('getProviderPriority'); final list = jsonDecode(result as String) as List; return list.map((e) => e as String).toList(); } - /// Set metadata provider priority order static Future setMetadataProviderPriority(List providerIds) async { _log.d('setMetadataProviderPriority: $providerIds'); await _channel.invokeMethod('setMetadataProviderPriority', { @@ -590,14 +517,12 @@ class PlatformBridge { }); } - /// Get metadata provider priority order static Future> getMetadataProviderPriority() async { final result = await _channel.invokeMethod('getMetadataProviderPriority'); final list = jsonDecode(result as String) as List; return list.map((e) => e as String).toList(); } - /// Get extension settings static Future> getExtensionSettings(String extensionId) async { final result = await _channel.invokeMethod('getExtensionSettings', { 'extension_id': extensionId, @@ -605,7 +530,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Set extension settings static Future setExtensionSettings(String extensionId, Map settings) async { _log.d('setExtensionSettings: $extensionId'); await _channel.invokeMethod('setExtensionSettings', { @@ -614,8 +538,6 @@ class PlatformBridge { }); } - /// Invoke an action on an extension (e.g., button click handler like "startLogin") - /// Returns the result from the JS function static Future> invokeExtensionAction(String extensionId, String actionName) async { _log.d('invokeExtensionAction: $extensionId.$actionName'); final result = await _channel.invokeMethod('invokeExtensionAction', { @@ -628,7 +550,6 @@ class PlatformBridge { return jsonDecode(result) as Map; } - /// Search tracks using extension providers static Future>> searchTracksWithExtensions(String query, {int limit = 20}) async { _log.d('searchTracksWithExtensions: "$query"'); final result = await _channel.invokeMethod('searchTracksWithExtensions', { @@ -639,7 +560,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Download with extension providers (includes fallback) static Future> downloadWithExtensions({ required String isrc, required String spotifyId, @@ -659,10 +579,9 @@ class PlatformBridge { String? releaseDate, String? itemId, int durationMs = 0, - String? source, // Extension ID that provided this track (prioritize this extension) + String? source, String? genre, String? label, - // Lyrics mode: "embed" (default), "external" (.lrc file), "both" String lyricsMode = 'embed', }) async { _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); @@ -685,10 +604,9 @@ class PlatformBridge { 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', 'duration_ms': durationMs, - 'source': source ?? '', // Extension ID that provided this track + 'source': source ?? '', 'genre': genre ?? '', 'label': label ?? '', - // Lyrics mode 'lyrics_mode': lyricsMode, }); @@ -696,15 +614,11 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Cleanup all extensions (call on app close) static Future cleanupExtensions() async { _log.d('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions'); } - // ==================== EXTENSION AUTH API ==================== - - /// Get pending auth request for an extension (if any) static Future?> getExtensionPendingAuth(String extensionId) async { final result = await _channel.invokeMethod('getExtensionPendingAuth', { 'extension_id': extensionId, @@ -713,7 +627,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Set auth code for an extension (after OAuth callback) static Future setExtensionAuthCode(String extensionId, String authCode) async { _log.d('setExtensionAuthCode: $extensionId'); await _channel.invokeMethod('setExtensionAuthCode', { @@ -722,7 +635,6 @@ class PlatformBridge { }); } - /// Set tokens for an extension (after token exchange) static Future setExtensionTokens( String extensionId, { required String accessToken, @@ -738,14 +650,12 @@ class PlatformBridge { }); } - /// Clear pending auth request for an extension static Future clearExtensionPendingAuth(String extensionId) async { await _channel.invokeMethod('clearExtensionPendingAuth', { 'extension_id': extensionId, }); } - /// Check if extension is authenticated static Future isExtensionAuthenticated(String extensionId) async { final result = await _channel.invokeMethod('isExtensionAuthenticated', { 'extension_id': extensionId, @@ -753,16 +663,12 @@ class PlatformBridge { return result as bool; } - /// Get all pending auth requests (for polling) static Future>> getAllPendingAuthRequests() async { final result = await _channel.invokeMethod('getAllPendingAuthRequests'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION FFMPEG API ==================== - - /// Get pending FFmpeg command for execution static Future?> getPendingFFmpegCommand(String commandId) async { final result = await _channel.invokeMethod('getPendingFFmpegCommand', { 'command_id': commandId, @@ -771,7 +677,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Set FFmpeg command result static Future setFFmpegCommandResult( String commandId, { required bool success, @@ -786,16 +691,12 @@ class PlatformBridge { }); } - /// Get all pending FFmpeg commands static Future>> getAllPendingFFmpegCommands() async { final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION CUSTOM SEARCH ==================== - - /// Perform custom search using an extension static Future>> customSearchWithExtension( String extensionId, String query, { @@ -810,17 +711,12 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Get all extensions that provide custom search static Future>> getSearchProviders() async { final result = await _channel.invokeMethod('getSearchProviders'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION URL HANDLER ==================== - - /// Handle a URL with any matching extension - /// Returns null if no extension can handle the URL static Future?> handleURLWithExtension(String url) async { try { final result = await _channel.invokeMethod('handleURLWithExtension', { @@ -833,8 +729,6 @@ class PlatformBridge { } } - /// Find an extension that can handle the given URL - /// Returns extension ID or null if none found static Future findURLHandler(String url) async { final result = await _channel.invokeMethod('findURLHandler', { 'url': url, @@ -843,14 +737,12 @@ class PlatformBridge { return result as String; } - /// Get all extensions that handle custom URLs static Future>> getURLHandlers() async { final result = await _channel.invokeMethod('getURLHandlers'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - /// Get album tracks using an extension static Future?> getAlbumWithExtension( String extensionId, String albumId, @@ -868,7 +760,6 @@ class PlatformBridge { } } - /// Get playlist tracks using an extension static Future?> getPlaylistWithExtension( String extensionId, String playlistId, @@ -886,7 +777,6 @@ class PlatformBridge { } } - /// Get artist info and albums using an extension static Future?> getArtistWithExtension( String extensionId, String artistId, @@ -904,9 +794,7 @@ class PlatformBridge { } } - // ==================== EXTENSION POST-PROCESSING ==================== - /// Run post-processing hooks on a file static Future> runPostProcessing( String filePath, { Map? metadata, @@ -918,22 +806,18 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get all extensions that provide post-processing static Future>> getPostProcessingProviders() async { final result = await _channel.invokeMethod('getPostProcessingProviders'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION STORE ==================== - /// Initialize extension store static Future initExtensionStore(String cacheDir) async { _log.d('initExtensionStore: $cacheDir'); await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); } - /// Get all extensions from store with installation status static Future>> getStoreExtensions({bool forceRefresh = false}) async { _log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); final result = await _channel.invokeMethod('getStoreExtensions', { @@ -943,7 +827,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Search extensions in store static Future>> searchStoreExtensions(String query, {String? category}) async { _log.d('searchStoreExtensions: "$query" (category: $category)'); final result = await _channel.invokeMethod('searchStoreExtensions', { @@ -954,14 +837,12 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Get store categories static Future> getStoreCategories() async { final result = await _channel.invokeMethod('getStoreCategories'); final list = jsonDecode(result as String) as List; return list.cast(); } - /// Download extension from store static Future downloadStoreExtension(String extensionId, String destDir) async { _log.i('downloadStoreExtension: $extensionId to $destDir'); final result = await _channel.invokeMethod('downloadStoreExtension', { @@ -971,7 +852,6 @@ class PlatformBridge { return result as String; } - /// Clear store cache static Future clearStoreCache() async { _log.d('clearStoreCache'); await _channel.invokeMethod('clearStoreCache'); diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 257e057c..36b032ec 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -4,7 +4,6 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('ShareIntent'); -/// Service to handle incoming share intents from other apps (e.g., Spotify) class ShareIntentService { static final ShareIntentService _instance = ShareIntentService._internal(); factory ShareIntentService() => _instance; @@ -15,17 +14,14 @@ class ShareIntentService { bool _initialized = false; String? _pendingUrl; // Store URL received before listener is ready - /// Stream of shared Spotify URLs Stream get sharedUrlStream => _sharedUrlController.stream; - /// Get pending URL that was received before listener was ready String? consumePendingUrl() { final url = _pendingUrl; _pendingUrl = null; return url; } - /// Initialize the service and start listening for share intents Future initialize() async { if (_initialized) return; _initialized = true; @@ -58,11 +54,6 @@ class ShareIntentService { } } - /// Extract Spotify URL from shared text - /// Handles various formats: - /// - Direct URL: https://open.spotify.com/track/xxx - /// - With text: "Check out this song! https://open.spotify.com/track/xxx" - /// - Spotify URI: spotify:track:xxx String? _extractSpotifyUrl(String text) { if (text.isEmpty) return null; @@ -83,7 +74,6 @@ class ShareIntentService { return null; } - /// Dispose resources void dispose() { _mediaSubscription?.cancel(); _sharedUrlController.close(); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 619abf83..2aba9e85 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/models/theme_settings.dart'; -/// App theme configuration for Material Expressive 3 class AppTheme { /// Default seed color (Spotify green) static const Color defaultSeedColor = Color(kDefaultSeedColor); - /// Create light theme static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) { final scheme = dynamicScheme ?? @@ -73,7 +71,6 @@ class AppTheme { ); } - /// AppBar theme static AppBarTheme _appBarTheme( ColorScheme scheme, { bool isAmoled = false, @@ -101,7 +98,6 @@ class AppTheme { surfaceTintColor: scheme.surfaceTint, ); - /// Elevated button theme static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) => ElevatedButtonThemeData( style: ElevatedButton.styleFrom( @@ -124,7 +120,6 @@ class AppTheme { ), ); - /// Outlined button theme static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) => OutlinedButtonThemeData( style: OutlinedButton.styleFrom( @@ -146,7 +141,6 @@ class AppTheme { ), ); - /// FAB theme static FloatingActionButtonThemeData _fabTheme(ColorScheme scheme) => FloatingActionButtonThemeData( elevation: 3, @@ -184,7 +178,6 @@ class AppTheme { ), // consistent padding ); - /// List tile theme static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData( shape: RoundedRectangleBorder( @@ -193,7 +186,6 @@ class AppTheme { contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ); - /// Dialog theme static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData( elevation: 6, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), @@ -213,7 +205,6 @@ class AppTheme { labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, ); - /// SnackBar theme static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData( behavior: SnackBarBehavior.floating, @@ -231,7 +222,6 @@ class AppTheme { circularTrackColor: scheme.surfaceContainerHighest, ); - /// Switch theme static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData( thumbColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { @@ -260,7 +250,6 @@ class AppTheme { selectedColor: scheme.secondaryContainer, ); - /// Divider theme static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1); } diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index bf6f0bec..3e69757b 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -/// Log entry with timestamp and level class LogEntry { final DateTime timestamp; final String level; @@ -38,7 +37,6 @@ class LogEntry { } } -/// Circular buffer for storing logs in memory class LogBuffer extends ChangeNotifier { static final LogBuffer _instance = LogBuffer._internal(); factory LogBuffer() => _instance; @@ -134,7 +132,6 @@ class LogBuffer extends ChangeNotifier { _lastGoLogIndex = nextIndex; } catch (e) { - // Ignore errors - Go backend might not be ready if (kDebugMode) { debugPrint('Failed to fetch Go logs: $e'); } @@ -180,7 +177,6 @@ class LogBuffer extends ChangeNotifier { } } -/// Custom log output that writes to both console and buffer class BufferedOutput extends LogOutput { final String tag; @@ -236,9 +232,6 @@ final log = Logger( level: Level.debug, ); -/// Logger with class/tag prefix for better traceability -/// Now also writes to LogBuffer for in-app viewing -/// Works in both debug and release mode class AppLogger { final String _tag; late final Logger? _logger; diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index 6c01e415..a983d818 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -2,10 +2,6 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; -/// A wrapper around CachedNetworkImage that uses persistent cache storage. -/// -/// This ensures cover images are cached to disk and persist across app restarts, -/// instead of being stored in the temporary directory that can be cleared by the OS. class CachedCoverImage extends StatelessWidget { final String imageUrl; final double? width; @@ -57,8 +53,6 @@ class CachedCoverImage extends StatelessWidget { } } -/// Provider for CachedNetworkImageProvider that uses persistent cache. -/// Use this for precacheImage() calls. CachedNetworkImageProvider cachedCoverImageProvider(String url) { return CachedNetworkImageProvider( url, From c36497e87c735a6dc3c134852b82efcf0aed7a50 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 03:25:33 +0700 Subject: [PATCH 45/48] perf: optimize widget rebuilds and reduce allocations - Cache SharedPreferences instance in DownloadHistoryNotifier and DownloadQueueNotifier - Precompile regex for folder sanitization and year extraction - Use indexWhere instead of firstWhere with placeholder object - Use selective watch for downloadQueueProvider (queuedCount, items) - Pass Track directly to _buildTrackTile instead of index lookup - Pass historyItems as parameter to _buildRecentAccess - Add extended metadata (genre, label, copyright) support for MP3 --- CHANGELOG.md | 16 +++-- lib/providers/download_queue_provider.dart | 72 ++++++++++++++-------- lib/screens/home_screen.dart | 47 +++++++------- lib/screens/home_tab.dart | 14 +++-- lib/screens/queue_screen.dart | 13 ++-- lib/screens/search_screen.dart | 35 ++++++----- 6 files changed, 114 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40296531..1ffa536a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,9 @@ # Changelog -## [Unreleased] - ## [3.1.3] - 2026-01-19 ### Added -- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory - - Cover images no longer disappear when app is closed or device restarts - - Cache stored in `app_flutter/cover_cache/` directory (not cleared by system) - - Maximum 1000 images cached for up to 365 days - - Covers are cached when displayed in History, Home, Album, Artist, or any other screen - - New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management - - **External LRC Lyrics File Support**: Option to save lyrics as separate .lrc files for compatibility with external music players - New "Lyrics Mode" setting in Settings > Download > Lyrics section - Three modes available: @@ -28,6 +19,13 @@ - Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3 - Respects "Ask quality before download" setting - uses default quality if disabled + - **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory + - Cover images no longer disappear when app is closed or device restarts + - Cache stored in `app_flutter/cover_cache/` directory (not cleared by system) + - Maximum 1000 images cached for up to 365 days + - Covers are cached when displayed in History, Home, Album, Artist, or any other screen + - New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management + - **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer - New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre` - Metadata fetched during `enrichTrack()` via Deezer album API diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ab7c7eaa..989cee9d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -26,6 +26,10 @@ String? _normalizeOptionalString(String? value) { return trimmed; } +final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]'); +final _trailingDotsRegex = RegExp(r'\.+$'); +final _yearRegex = RegExp(r'^(\d{4})'); + class DownloadHistoryItem { final String id; final String trackName; @@ -143,6 +147,7 @@ class DownloadHistoryState { class DownloadHistoryNotifier extends Notifier { static const _storageKey = 'download_history'; + final Future _prefs = SharedPreferences.getInstance(); bool _isLoaded = false; @override @@ -162,7 +167,7 @@ class DownloadHistoryNotifier extends Notifier { Future _loadFromStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final jsonStr = prefs.getString(_storageKey); if (jsonStr != null && jsonStr.isNotEmpty) { final List jsonList = jsonDecode(jsonStr); @@ -223,7 +228,7 @@ class DownloadHistoryNotifier extends Notifier { Future _saveToStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final jsonList = state.items.map((e) => e.toJson()).toList(); await prefs.setString(_storageKey, jsonEncode(jsonList)); _historyLog.d('Saved ${state.items.length} items to storage'); @@ -385,6 +390,7 @@ class DownloadQueueNotifier extends Notifier { static const _cleanupInterval = 50; static const _queueStorageKey = 'download_queue'; final NotificationService _notificationService = NotificationService(); + final Future _prefs = SharedPreferences.getInstance(); int _totalQueuedAtStart = 0; int _completedInSession = 0; int _failedInSession = 0; @@ -410,7 +416,7 @@ class DownloadQueueNotifier extends Notifier { _isLoaded = true; try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final jsonStr = prefs.getString(_queueStorageKey); if (jsonStr != null && jsonStr.isNotEmpty) { final List jsonList = jsonDecode(jsonStr); @@ -448,7 +454,7 @@ class DownloadQueueNotifier extends Notifier { Future _saveQueueToStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final pendingItems = state.items .where( @@ -783,15 +789,15 @@ class DownloadQueueNotifier extends Notifier { String _sanitizeFolderName(String name) { return name - .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') - .replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots + .replaceAll(_invalidFolderChars, '_') + .replaceAll(_trailingDotsRegex, '') // Remove trailing dots .trim(); } /// Extract year from release date (format: "2005-06-13" or "2005") String? _extractYear(String? releaseDate) { if (releaseDate == null || releaseDate.isEmpty) return null; - final match = RegExp(r'^(\d{4})').firstMatch(releaseDate); + final match = _yearRegex.firstMatch(releaseDate); return match?.group(1); } @@ -1216,7 +1222,13 @@ class DownloadQueueNotifier extends Notifier { } } - Future _embedMetadataToMp3(String mp3Path, Track track) async { + Future _embedMetadataToMp3( + String mp3Path, + Track track, { + String? genre, + String? label, + String? copyright, + }) async { final settings = ref.read(settingsProvider); String? coverPath; @@ -1283,6 +1295,19 @@ class DownloadQueueNotifier extends Notifier { metadata['ISRC'] = track.isrc!; } + if (genre != null && genre.isNotEmpty) { + metadata['GENRE'] = genre; + _log.d('Adding GENRE to MP3: $genre'); + } + if (label != null && label.isNotEmpty) { + metadata['ORGANIZATION'] = label; + _log.d('Adding ORGANIZATION (label) to MP3: $label'); + } + if (copyright != null && copyright.isNotEmpty) { + metadata['COPYRIGHT'] = copyright; + _log.d('Adding COPYRIGHT to MP3: $copyright'); + } + _log.d('MP3 Metadata map content: $metadata'); if (settings.embedLyrics) { @@ -1447,29 +1472,17 @@ class DownloadQueueNotifier extends Notifier { } final currentItems = state.items; - final nextItem = currentItems.firstWhere( + final nextIndex = currentItems.indexWhere( (item) => item.status == DownloadStatus.queued, - orElse: () => DownloadItem( - id: '', - track: const Track( - id: '', - name: '', - artistName: '', - albumName: '', - duration: 0, - ), - service: '', - createdAt: DateTime.now(), - ), ); - - if (nextItem.id.isEmpty) { + if (nextIndex == -1) { _log.d( 'No more items to process (checked ${currentItems.length} items)', ); break; } + final nextItem = currentItems[nextIndex]; _log.d( 'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})', ); @@ -1956,7 +1969,18 @@ class DownloadQueueNotifier extends Notifier { DownloadStatus.downloading, progress: 0.99, ); - await _embedMetadataToMp3(mp3Path, trackToDownload); + + final mp3BackendGenre = result['genre'] as String?; + final mp3BackendLabel = result['label'] as String?; + final mp3BackendCopyright = result['copyright'] as String?; + + await _embedMetadataToMp3( + mp3Path, + trackToDownload, + genre: mp3BackendGenre ?? genre, + label: mp3BackendLabel ?? label, + copyright: mp3BackendCopyright, + ); } else { _log.w('MP3 conversion failed, keeping FLAC file'); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 00cd98a6..2d89bc5e 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -46,16 +46,15 @@ class _HomeScreenState extends ConsumerState { } } - void _downloadTrack(int index) { - final trackState = ref.read(trackProvider); - if (index >= 0 && index < trackState.tracks.length) { - final track = trackState.tracks[index]; - final settings = ref.read(settingsProvider); - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added "${track.name}" to queue')), - ); - } + void _downloadTrack(Track track) { + final settings = ref.read(settingsProvider); + ref.read(downloadQueueProvider.notifier).addToQueue( + track, + settings.defaultService, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added "${track.name}" to queue')), + ); } void _downloadAll() { @@ -89,8 +88,10 @@ class _HomeScreenState extends ConsumerState { @override Widget build(BuildContext context) { final trackState = ref.watch(trackProvider); - final queueState = ref.watch(downloadQueueProvider); + final queuedCount = + ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final colorScheme = Theme.of(context).colorScheme; + final tracks = trackState.tracks; return Scaffold( appBar: AppBar( @@ -146,13 +147,13 @@ class _HomeScreenState extends ConsumerState { if (trackState.albumName != null || trackState.playlistName != null) _buildHeader(trackState, colorScheme), - if (trackState.tracks.length > 1) + if (tracks.length > 1) Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: FilledButton.icon( onPressed: _downloadAll, icon: const Icon(Icons.download), - label: Text('Download All (${trackState.tracks.length})'), + label: Text('Download All (${tracks.length})'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), ), @@ -160,11 +161,12 @@ class _HomeScreenState extends ConsumerState { ), Expanded( - child: trackState.tracks.isEmpty + child: tracks.isEmpty ? _buildEmptyState(colorScheme) : ListView.builder( - itemCount: trackState.tracks.length, - itemBuilder: (context, index) => _buildTrackTile(index, colorScheme), + itemCount: tracks.length, + itemBuilder: (context, index) => + _buildTrackTile(tracks[index], colorScheme), ), ), ], @@ -180,13 +182,13 @@ class _HomeScreenState extends ConsumerState { ), NavigationDestination( icon: Badge( - isLabelVisible: queueState.queuedCount > 0, - label: Text('${queueState.queuedCount}'), + isLabelVisible: queuedCount > 0, + label: Text('$queuedCount'), child: const Icon(Icons.queue_music_outlined), ), selectedIcon: Badge( - isLabelVisible: queueState.queuedCount > 0, - label: Text('${queueState.queuedCount}'), + isLabelVisible: queuedCount > 0, + label: Text('$queuedCount'), child: const Icon(Icons.queue_music), ), label: 'Queue', @@ -261,8 +263,7 @@ child: CachedNetworkImage( ); } - Widget _buildTrackTile(int index, ColorScheme colorScheme) { - final track = ref.watch(trackProvider).tracks[index]; + Widget _buildTrackTile(Track track, ColorScheme colorScheme) { final isCollection = track.isCollection; String subtitleText; @@ -318,7 +319,7 @@ child: CachedNetworkImage( color: colorScheme.onSurfaceVariant, ), ), - onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index), + onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track), ); } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 7339bbba..af5413a3 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -548,7 +548,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (showRecentAccess) SliverToBoxAdapter( - child: _buildRecentAccess(recentAccessItems, colorScheme), + child: _buildRecentAccess( + recentAccessItems, + historyItems, + colorScheme, + ), ), SliverToBoxAdapter( @@ -666,9 +670,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - Widget _buildRecentAccess(List items, ColorScheme colorScheme) { - final historyItems = ref.read(downloadHistoryProvider).items; - + Widget _buildRecentAccess( + List items, + List historyItems, + ColorScheme colorScheme, + ) { // Group download history by album final albumGroups = >{}; for (final h in historyItems) { diff --git a/lib/screens/queue_screen.dart b/lib/screens/queue_screen.dart index aa38fad7..b649d496 100644 --- a/lib/screens/queue_screen.dart +++ b/lib/screens/queue_screen.dart @@ -11,20 +11,20 @@ class QueueScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final queueState = ref.watch(downloadQueueProvider); + final items = ref.watch(downloadQueueProvider.select((s) => s.items)); final colorScheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( title: Text(context.l10n.queueTitle), actions: [ - if (queueState.items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.delete_sweep), onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(), tooltip: context.l10n.queueClearCompleted, ), - if (queueState.items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.clear_all), onPressed: () => _showClearAllDialog(context, ref), @@ -32,11 +32,12 @@ class QueueScreen extends ConsumerWidget { ), ], ), - body: queueState.items.isEmpty + body: items.isEmpty ? _buildEmptyState(context, colorScheme) : ListView.builder( - itemCount: queueState.items.length, - itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme), + itemCount: items.length, + itemBuilder: (context, index) => + _buildQueueItem(context, ref, items[index], colorScheme), ), ); } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 88377d51..0788ecb9 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -44,22 +45,22 @@ class _SearchScreenState extends ConsumerState { } } - void _downloadTrack(int index) { - final trackState = ref.read(trackProvider); - if (index >= 0 && index < trackState.tracks.length) { - final track = trackState.tracks[index]; - final settings = ref.read(settingsProvider); - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added "${track.name}" to queue')), - ); - } + void _downloadTrack(Track track) { + final settings = ref.read(settingsProvider); + ref.read(downloadQueueProvider.notifier).addToQueue( + track, + settings.defaultService, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added "${track.name}" to queue')), + ); } @override Widget build(BuildContext context) { final trackState = ref.watch(trackProvider); final colorScheme = Theme.of(context).colorScheme; + final tracks = trackState.tracks; return Scaffold( appBar: AppBar( @@ -96,11 +97,12 @@ class _SearchScreenState extends ConsumerState { ), ), Expanded( - child: trackState.tracks.isEmpty + child: tracks.isEmpty ? _buildEmptyState(colorScheme) : ListView.builder( - itemCount: trackState.tracks.length, - itemBuilder: (context, index) => _buildTrackTile(index, colorScheme), + itemCount: tracks.length, + itemBuilder: (context, index) => + _buildTrackTile(tracks[index], colorScheme), ), ), ], @@ -130,8 +132,7 @@ class _SearchScreenState extends ConsumerState { ); } - Widget _buildTrackTile(int index, ColorScheme colorScheme) { - final track = ref.watch(trackProvider).tracks[index]; + Widget _buildTrackTile(Track track, ColorScheme colorScheme) { return ListTile( leading: track.coverUrl != null ? ClipRRect( @@ -175,9 +176,9 @@ child: CachedNetworkImage( ), trailing: IconButton( icon: Icon(Icons.download, color: colorScheme.primary), - onPressed: () => _downloadTrack(index), + onPressed: () => _downloadTrack(track), ), - onTap: () => _downloadTrack(index), + onTap: () => _downloadTrack(track), ); } } From d99a1b1c214c76d5bc9f572c729931f94076885f Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 03:46:43 +0700 Subject: [PATCH 46/48] perf: streaming M4A metadata embedding and HTTP client refactor - Refactor EmbedM4AMetadata to use streaming instead of loading entire file - Use os.Open + ReadAt instead of os.ReadFile for memory efficiency - Atomic file replacement via temp file + rename for safer writes - New helper functions: findAtomInRange, readAtomHeaderAt, copyRange, buildUdtaAtom - Refactor GetM4AQuality to use streaming with findAudioSampleEntry - Use NewHTTPClientWithTimeout helper in lyrics.go, qobuz.go, tidal.go - Update CHANGELOG with performance improvements and MP3 metadata support --- CHANGELOG.md | 17 ++ go_backend/lyrics.go | 4 +- go_backend/metadata.go | 425 ++++++++++++++++++++++++++++++++++------- go_backend/qobuz.go | 4 +- go_backend/tidal.go | 8 +- 5 files changed, 380 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ffa536a..05962b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,10 @@ - Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()` - Tags correctly embedded during FFmpeg conversion +- **Extended Metadata for MP3 Conversion**: Genre, label, and copyright now embedded in MP3 files when converting from FLAC + - Added `genre`, `label`, `copyright` parameters to `_embedMetadataToMp3()` + - Tags embedded as ID3v2: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT` + ### Extensions - **spotify-web Extension**: Updated to v1.7.0 @@ -92,11 +96,24 @@ - File stat uses a single syscall and only triggers state updates on change - Static regex/month table avoids repeated allocations - Cover precached before opening metadata from history/queue/recents +- **Flutter Provider Optimizations**: + - Cache `SharedPreferences` instance in `DownloadHistoryNotifier` and `DownloadQueueNotifier` to avoid repeated `getInstance()` calls + - Precompile regex for folder name sanitization and year extraction (top-level `final`) + - Use `indexWhere` instead of `firstWhere` with placeholder object to reduce allocations in queue processing +- **Flutter UI Optimizations**: + - Selective `ref.watch()` for `downloadQueueProvider` (watch only `queuedCount` or `items` instead of entire state) + - Pass `Track` directly to `_buildTrackTile()` instead of index lookup inside builder + - Pass `historyItems` as parameter to `_buildRecentAccess()` to avoid `ref.read()` inside method +- **M4A Metadata Embedding**: Streaming implementation reduces memory usage for large files + - Uses `os.Open()` + `ReadAt` instead of `os.ReadFile()` (no full file load into memory) + - Atomic file replacement via temp file + rename for safer writes + - New helper functions: `findAtomInRange()`, `readAtomHeaderAt()`, `copyRange()` ### Backend - **Deezer ISRC Fetching**: Uses ISRCs already present in payloads and caches them, cutting extra API calls - **SearchAll Allocation**: Preallocated slices to reduce allocations during Deezer search +- **HTTP Client Helper**: Refactored HTTP client creation to use `NewHTTPClientWithTimeout()` helper function across `lyrics.go`, `qobuz.go`, `tidal.go` ### Technical diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 895da25a..b22b200a 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -123,9 +123,7 @@ type LyricsClient struct { func NewLyricsClient() *LyricsClient { return &LyricsClient{ - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + httpClient: NewHTTPClientWithTimeout(15 * time.Second), } } diff --git a/go_backend/metadata.go b/go_backend/metadata.go index a30fc684..f2dac03d 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -1,7 +1,10 @@ package gobackend import ( + "bytes" + "encoding/binary" "fmt" + "io" "os" "strconv" "strings" @@ -515,73 +518,166 @@ func GetAudioQuality(filePath string) (AudioQuality, error) { // EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error { - data, err := os.ReadFile(filePath) + input, err := os.Open(filePath) if err != nil { - return fmt.Errorf("failed to read M4A file: %w", err) + return fmt.Errorf("failed to open M4A file: %w", err) } + defer input.Close() - moovPos := findAtom(data, "moov", 0) - if moovPos < 0 { + info, err := input.Stat() + if err != nil { + return fmt.Errorf("failed to stat M4A file: %w", err) + } + fileSize := info.Size() + + moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize) + if err != nil { + return fmt.Errorf("failed to find moov atom: %w", err) + } + if !moovFound { return fmt.Errorf("moov atom not found in M4A file") } - moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3])) - udtaPos := findAtom(data, "udta", moovPos+8) + moovContentStart := moovHeader.offset + moovHeader.headerSize + moovContentSize := moovHeader.size - moovHeader.headerSize + + udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize) + if err != nil { + return fmt.Errorf("failed to locate udta atom: %w", err) + } + + var metaHeader atomHeader + metaFound := false + if udtaFound { + udtaContentStart := udtaHeader.offset + udtaHeader.headerSize + udtaContentSize := udtaHeader.size - udtaHeader.headerSize + metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize) + if err != nil { + return fmt.Errorf("failed to locate meta atom: %w", err) + } + } metaAtom := buildMetaAtom(metadata, coverData) + metaSize := int64(len(metaAtom)) - var newData []byte - if udtaPos >= 0 && udtaPos < moovPos+moovSize { - udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3])) - metaPos := findAtom(data, "meta", udtaPos+8) + var delta int64 + var newUdtaSize int64 + switch { + case udtaFound && metaFound: + delta = metaSize - metaHeader.size + newUdtaSize = udtaHeader.size + delta + case udtaFound && !metaFound: + delta = metaSize + newUdtaSize = udtaHeader.size + delta + case !udtaFound: + newUdtaSize = int64(8 + len(metaAtom)) + delta = newUdtaSize + } - if metaPos >= 0 && metaPos < udtaPos+udtaSize { - metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3])) - newData = append(newData, data[:metaPos]...) - newData = append(newData, metaAtom...) - newData = append(newData, data[metaPos+metaSize:]...) - } else { - newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...) - newUdtaSize := 8 + len(newUdtaContent) - newUdta := make([]byte, 4) - newUdta[0] = byte(newUdtaSize >> 24) - newUdta[1] = byte(newUdtaSize >> 16) - newUdta[2] = byte(newUdtaSize >> 8) - newUdta[3] = byte(newUdtaSize) - newUdta = append(newUdta, []byte("udta")...) - newUdta = append(newUdta, newUdtaContent...) + newMoovSize := moovHeader.size + delta + if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) { + return fmt.Errorf("moov atom exceeds 32-bit size after update") + } + if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) { + return fmt.Errorf("udta atom exceeds 32-bit size after update") + } + if !udtaFound && newUdtaSize > int64(^uint32(0)) { + return fmt.Errorf("udta atom exceeds 32-bit size after update") + } - newData = append(newData, data[:udtaPos]...) - newData = append(newData, newUdta...) - newData = append(newData, data[udtaPos+udtaSize:]...) + tempPath := filePath + ".tmp" + output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + cleanupTemp := true + defer func() { + _ = output.Close() + if cleanupTemp { + _ = os.Remove(tempPath) } - } else { - udtaContent := metaAtom - udtaSize := 8 + len(udtaContent) - newUdta := make([]byte, 4) - newUdta[0] = byte(udtaSize >> 24) - newUdta[1] = byte(udtaSize >> 16) - newUdta[2] = byte(udtaSize >> 8) - newUdta[3] = byte(udtaSize) - newUdta = append(newUdta, []byte("udta")...) - newUdta = append(newUdta, udtaContent...) + }() - insertPos := moovPos + moovSize - newData = append(newData, data[:insertPos]...) - newData = append(newData, newUdta...) - newData = append(newData, data[insertPos:]...) + switch { + case udtaFound && metaFound: + if err := copyRange(output, input, 0, moovHeader.offset); err != nil { + return err + } + if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil { + return err + } + if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil { + return err + } + if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil { + return err + } + if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil { + return err + } + if _, err := output.Write(metaAtom); err != nil { + return fmt.Errorf("failed to write meta atom: %w", err) + } + metaEnd := metaHeader.offset + metaHeader.size + if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil { + return err + } + case udtaFound && !metaFound: + if err := copyRange(output, input, 0, moovHeader.offset); err != nil { + return err + } + if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil { + return err + } + if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil { + return err + } + if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil { + return err + } + insertPos := udtaHeader.offset + udtaHeader.size + if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil { + return err + } + if _, err := output.Write(metaAtom); err != nil { + return fmt.Errorf("failed to write meta atom: %w", err) + } + if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil { + return err + } + case !udtaFound: + newUdtaAtom := buildUdtaAtom(metaAtom) + if err := copyRange(output, input, 0, moovHeader.offset); err != nil { + return err + } + if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil { + return err + } + moovEnd := moovHeader.offset + moovHeader.size + if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil { + return err + } + if _, err := output.Write(newUdtaAtom); err != nil { + return fmt.Errorf("failed to write udta atom: %w", err) + } + if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil { + return err + } } - newMoovSize := moovSize + len(newData) - len(data) - newData[moovPos] = byte(newMoovSize >> 24) - newData[moovPos+1] = byte(newMoovSize >> 16) - newData[moovPos+2] = byte(newMoovSize >> 8) - newData[moovPos+3] = byte(newMoovSize) - - if err := os.WriteFile(filePath, newData, 0644); err != nil { - return fmt.Errorf("failed to write M4A file: %w", err) + if err := output.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) } + _ = input.Close() + if err := os.Remove(filePath); err != nil { + return fmt.Errorf("failed to replace original file: %w", err) + } + if err := os.Rename(tempPath, filePath); err != nil { + return fmt.Errorf("failed to move temp file: %w", err) + } + cleanupTemp = false + fmt.Printf("[M4A] Metadata embedded successfully\n") return nil } @@ -782,28 +878,225 @@ func buildCoverAtom(coverData []byte) []byte { } func GetM4AQuality(filePath string) (AudioQuality, error) { - data, err := os.ReadFile(filePath) + f, err := os.Open(filePath) if err != nil { - return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err) + return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err) } + defer f.Close() - moovPos := findAtom(data, "moov", 0) - if moovPos < 0 { + info, err := f.Stat() + if err != nil { + return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err) + } + fileSize := info.Size() + + moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) + if err != nil { + return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err) + } + if !moovFound { return AudioQuality{}, fmt.Errorf("moov atom not found") } - for i := moovPos; i < len(data)-20; i++ { - if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" { - if i+24 < len(data) { - sampleRate := int(data[i+22])<<8 | int(data[i+23]) - bitDepth := 16 - if string(data[i:i+4]) == "alac" { - bitDepth = 24 - } - return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil - } - } + moovStart := moovHeader.offset + moovEnd := moovHeader.offset + moovHeader.size + + sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize) + if err != nil { + return AudioQuality{}, err } - return AudioQuality{}, fmt.Errorf("audio info not found in M4A file") + buf := make([]byte, 24) + if _, err := f.ReadAt(buf, sampleOffset); err != nil { + return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err) + } + + sampleRate := int(buf[22])<<8 | int(buf[23]) + bitDepth := 16 + if atomType == "alac" { + bitDepth = 24 + } + + return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil +} + +type atomHeader struct { + offset int64 + size int64 + headerSize int64 + typ string +} + +func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) { + if offset+8 > fileSize { + return atomHeader{}, io.ErrUnexpectedEOF + } + + headerBuf := make([]byte, 8) + if _, err := f.ReadAt(headerBuf, offset); err != nil { + return atomHeader{}, err + } + + size32 := binary.BigEndian.Uint32(headerBuf[0:4]) + typ := string(headerBuf[4:8]) + + if size32 == 1 { + if offset+16 > fileSize { + return atomHeader{}, io.ErrUnexpectedEOF + } + extBuf := make([]byte, 8) + if _, err := f.ReadAt(extBuf, offset+8); err != nil { + return atomHeader{}, err + } + size64 := binary.BigEndian.Uint64(extBuf) + return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil + } + + return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil +} + +func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) { + if size <= 0 { + return atomHeader{}, false, nil + } + + end := start + size + pos := start + + for pos+8 <= end { + header, err := readAtomHeaderAt(f, pos, fileSize) + if err != nil { + return atomHeader{}, false, err + } + + atomSize := header.size + if atomSize == 0 { + atomSize = end - pos + } + + if atomSize < header.headerSize { + return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ) + } + + header.size = atomSize + if header.typ == target { + return header, true, nil + } + + pos += atomSize + } + + return atomHeader{}, false, nil +} + +func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error { + if len(typ) != 4 { + return fmt.Errorf("invalid atom type: %s", typ) + } + + if headerSize == 16 { + header := make([]byte, 16) + binary.BigEndian.PutUint32(header[0:4], 1) + copy(header[4:8], []byte(typ)) + binary.BigEndian.PutUint64(header[8:16], uint64(size)) + _, err := w.Write(header) + return err + } + + if size > int64(^uint32(0)) { + return fmt.Errorf("atom size exceeds 32-bit for %s", typ) + } + + header := make([]byte, 8) + binary.BigEndian.PutUint32(header[0:4], uint32(size)) + copy(header[4:8], []byte(typ)) + _, err := w.Write(header) + return err +} + +func copyRange(dst io.Writer, src *os.File, offset, length int64) error { + if length <= 0 { + return nil + } + if _, err := src.Seek(offset, io.SeekStart); err != nil { + return fmt.Errorf("failed to seek source: %w", err) + } + if _, err := io.CopyN(dst, src, length); err != nil { + return fmt.Errorf("failed to copy data: %w", err) + } + return nil +} + +func buildUdtaAtom(metaAtom []byte) []byte { + size := 8 + len(metaAtom) + header := make([]byte, 8) + binary.BigEndian.PutUint32(header[0:4], uint32(size)) + copy(header[4:8], []byte("udta")) + return append(header, metaAtom...) +} + +func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) { + const chunkSize = 64 * 1024 + patternMP4A := []byte("mp4a") + patternALAC := []byte("alac") + + var tail []byte + readPos := start + + for readPos < end { + toRead := end - readPos + if toRead > chunkSize { + toRead = chunkSize + } + + buf := make([]byte, toRead) + n, err := f.ReadAt(buf, readPos) + if err != nil && err != io.EOF { + return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err) + } + if n == 0 { + break + } + + data := append(tail, buf[:n]...) + mp4aIdx := bytes.Index(data, patternMP4A) + alacIdx := bytes.Index(data, patternALAC) + + bestIdx := -1 + bestType := "" + switch { + case mp4aIdx >= 0 && alacIdx >= 0: + if mp4aIdx <= alacIdx { + bestIdx = mp4aIdx + bestType = "mp4a" + } else { + bestIdx = alacIdx + bestType = "alac" + } + case mp4aIdx >= 0: + bestIdx = mp4aIdx + bestType = "mp4a" + case alacIdx >= 0: + bestIdx = alacIdx + bestType = "alac" + } + + if bestIdx >= 0 { + absolute := readPos - int64(len(tail)) + int64(bestIdx) + if absolute+24 > fileSize { + return 0, "", fmt.Errorf("audio info not found in M4A file") + } + return absolute, bestType, nil + } + + if len(data) >= 3 { + tail = append([]byte{}, data[len(data)-3:]...) + } else { + tail = append([]byte{}, data...) + } + + readPos += int64(n) + } + + return 0, "", fmt.Errorf("audio info not found in M4A file") } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index d3a777ac..40c24d86 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -716,9 +716,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( go func(api string) { reqStart := time.Now() - client := &http.Client{ - Timeout: 15 * time.Second, - } + client := NewHTTPClientWithTimeout(15 * time.Second) reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 8c245b64..f489741a 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -600,9 +600,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin go func(api string) { reqStart := time.Now() - client := &http.Client{ - Timeout: 15 * time.Second, - } + client := NewHTTPClientWithTimeout(15 * time.Second) reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) @@ -901,9 +899,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n", directURL != "", initURL != "", len(mediaURLs)) - client := &http.Client{ - Timeout: 120 * time.Second, - } + client := NewHTTPClientWithTimeout(120 * time.Second) if directURL != "" { GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) From bb1ff187a38efd88d35f350f9b235de1fe74a087 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 03:59:55 +0700 Subject: [PATCH 47/48] fix: include genre, label, copyright in DownloadResponse Extended metadata was being embedded into FLAC files but not returned in the response to Flutter, causing history to not store these fields. Fixed in 3 places in extension_providers.go: - Source extension download response - Extension fallback download response - Built-in provider (Tidal/Qobuz/Amazon) response --- go_backend/extension_providers.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 83c63629..6c202583 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -848,6 +848,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro ActualBitDepth: result.BitDepth, ActualSampleRate: result.SampleRate, Service: req.Source, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } // Embed genre and label if provided (from Deezer metadata) @@ -1010,6 +1013,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro ActualBitDepth: result.BitDepth, ActualSampleRate: result.SampleRate, Service: providerID, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } // Embed genre and label if provided (from Deezer metadata) @@ -1169,6 +1175,9 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon TrackNumber: result.TrackNumber, DiscNumber: result.DiscNumber, ISRC: result.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, }, nil } From f356e53f7ec13f7254508ed6dd00c21edf102770 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 04:09:41 +0700 Subject: [PATCH 48/48] feat: auto-enrich genre/label from Deezer for built-in providers - Add GetExtendedMetadataByISRC function in deezer.go - Searches track by ISRC then fetches album extended metadata - Call enrichment in DownloadWithExtensionFallback before built-in download - Only enriches if genre/label are empty and ISRC is available - Logs enrichment results for debugging --- go_backend/deezer.go | 26 ++++++++++++++++++++++++++ go_backend/extension_providers.go | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index cb82a7f4..2fb92c58 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -766,6 +766,32 @@ func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID return c.GetAlbumExtendedMetadata(ctx, albumID) } +// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label) +func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) { + if isrc == "" { + return nil, fmt.Errorf("empty ISRC") + } + + // First, search for track by ISRC + track, err := c.SearchByISRC(ctx, isrc) + if err != nil { + return nil, fmt.Errorf("failed to find track by ISRC: %w", err) + } + + // SpotifyID contains "deezer:123" format, extract the ID + deezerID := track.SpotifyID + if strings.HasPrefix(deezerID, "deezer:") { + deezerID = strings.TrimPrefix(deezerID, "deezer:") + } + + if deezerID == "" { + return nil, fmt.Errorf("track found but no Deezer ID") + } + + // Then fetch extended metadata using the Deezer track ID + return c.GetExtendedMetadataByTrackID(ctx, deezerID) +} + func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 6c202583..672a06c9 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -2,6 +2,7 @@ package gobackend import ( + "context" "encoding/json" "errors" "fmt" @@ -943,6 +944,27 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) if isBuiltInProvider(providerID) { + // For built-in providers, enrich with Deezer metadata if not already present + if (req.Genre == "" || req.Label == "") && req.ISRC != "" { + GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + deezerClient := GetDeezerClient() + extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC) + cancel() + if err == nil && extMeta != nil { + if req.Genre == "" && extMeta.Genre != "" { + req.Genre = extMeta.Genre + GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre) + } + if req.Label == "" && extMeta.Label != "" { + req.Label = extMeta.Label + GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label) + } + } else if err != nil { + GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err) + } + } + // Use built-in provider result, err := tryBuiltInProvider(providerID, req) if err == nil && result.Success {