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