diff --git a/CHANGELOG.md b/CHANGELOG.md index a2cbf055..71e18c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ ### Added +- **Recent Access History**: Quick access to recently visited content when tapping the search bar + - Shows recently visited artists, albums, playlists, and downloaded tracks + - Merged view combining navigation history and download history + - Tap to quickly navigate back to previously accessed content + - X button to remove individual items from history + - "Clear All" button to clear entire history + - Persists across app restarts (stored in SharedPreferences) + - Max 20 items stored, sorted by most recent + - Multi-language support (Artist/Album/Song/Playlist labels localized) + +- **Artist Screen Redesign** + - Full-width header image (380px) with gradient overlay + - Artist name displayed at bottom of header with text shadow + - Monthly listeners count display (formatted with compact notation) + - "Popular" section showing top 5 tracks with download status indicators + - Dynamic download button states (queued, downloading, completed) + - Header image and top tracks fetched from extension metadata + - Image alignment set to top-center to show faces properly + +- **Extension Store Update Badge**: Badge indicator on Store tab icon showing number of available updates + - Users can see extension updates are available without opening Store tab + - Badge shows count of extensions with updates + +- **Extension Compatibility Warning**: Warning badge for extensions requiring newer app version + - Extensions with `minAppVersion` higher than current app show warning label + - Label displays "Requires vX.X.X+" to encourage users to upgrade + - Users can still install the extension (not blocked) + - **Year in Album Folder Name** ([#50](https://github.com/zarzet/SpotiFLAC-Mobile/issues/50)): New album folder structure options with release year - `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/ @@ -23,11 +51,18 @@ - **Odesli (song.link) Integration for YouTube Music Extension** - New `enrichTrack()` function to fetch ISRC and external service links - - Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz/Spotify + - Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz - Enables built-in service fallback for high-quality audio downloads - Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions - **Download Cancel**: Canceling a download now stops in-flight built-in provider downloads (Tidal/Qobuz/Amazon) and clears backend progress tracking. +### Changed + +- **Search Bar Behavior**: Tapping search bar now immediately moves it to top position + - Logo and subtitle hide when search bar is focused + - Recent access history appears in the content area below + - More space for recent items, not blocked by keyboard + ### Fixed - Fixed search source chips still referencing removed badge props. @@ -54,6 +89,9 @@ - Fixed search results mixing extension and built-in artists when using default provider. - Fixed audio files opening with non-music apps by passing audio MIME type on open. - Fixed album artist showing null/blank by normalizing empty metadata and using artist fallback for tags. +- Fixed `use_build_context_synchronously` lint warnings in `home_tab.dart` +- Fixed `unnecessary_underscores` lint warnings in error widget callbacks +- Fixed duplicate artist entries in recent history (recording now only happens in screen's initState) - **Go Backend: Missing `item_type` and `album_type` fields** - Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct - Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response @@ -62,6 +100,36 @@ - Fixed `GetPlaylistWithExtensionJSON` - now includes `item_type` and `album_type` for playlist tracks - **Album/Playlist Track Thumbnails**: Tracks inside albums/playlists now use album/playlist cover as fallback when no individual cover exists - **YouTube Music Extension getArtist**: Fixed `getArtist()` function not being registered in extension, causing artist pages to fail with "returned null" error +- **Recent Access UI**: Fixed recent access list disappearing when keyboard is dismissed - now stays visible until user presses Back button +- **Extension Artist Top Tracks**: Fixed top tracks not appearing when opening artist from extension search results + - YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs + - Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter + - `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen` + - `ArtistScreen` with `extensionId` skips Spotify/Deezer fetch, uses extension data only (fixes "Rate Limited" errors) +- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field +- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus +- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, Spotify Web) instead of trying to fetch from Spotify API + +### Extensions + +- **YouTube Music Extension**: Updated to v1.5.0 + - `getArtist()` now returns `top_tracks` array with popular songs + - Added `header_image` and `listeners` to artist response +- **Spotify Web Extension**: Updated to v1.6.0 + +### Localization + +- **Multi-Language Support**: App now supports multiple languages with community contributions via Crowdin + - Available languages: English, Indonesian (Bahasa Indonesia) + - More languages coming soon with community translations + - Contribute translations at [Crowdin](https://crowdin.com/project/spotiflac-mobile) +- Added new localization strings for recent access types: + - `recentTypeArtist` - "Artist" / "Artis" + - `recentTypeAlbum` - "Album" / "Album" + - `recentTypeSong` - "Song" / "Lagu" + - `recentTypePlaylist` - "Playlist" / "Playlist" + - `recentPlaylistInfo` - "Playlist: {name}" + - `errorGeneric` - "Error: {message}" --- diff --git a/go_backend/exports.go b/go_backend/exports.go index 9c9c15ed..76972991 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1657,10 +1657,12 @@ func HandleURLWithExtensionJSON(url string) (string, error) { // Add artist info if present if result.Artist != nil { artistResponse := map[string]interface{}{ - "id": result.Artist.ID, - "name": result.Artist.Name, - "image_url": result.Artist.ImageURL, - "provider_id": result.Artist.ProviderID, + "id": result.Artist.ID, + "name": result.Artist.Name, + "image_url": result.Artist.ImageURL, + "header_image": result.Artist.HeaderImage, + "listeners": result.Artist.Listeners, + "provider_id": result.Artist.ProviderID, } // Add albums if present @@ -1686,6 +1688,29 @@ func HandleURLWithExtensionJSON(url string) (string, error) { artistResponse["albums"] = albums } + // Add top tracks if present + if len(result.Artist.TopTracks) > 0 { + topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks)) + for i, track := range result.Artist.TopTracks { + topTracks[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.ResolvedCoverURL(), + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "spotify_id": track.SpotifyID, + } + } + artistResponse["top_tracks"] = topTracks + } + response["artist"] = artistResponse } @@ -1920,6 +1945,39 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { "provider_id": artist.ProviderID, } + // Add header image if present + if artist.HeaderImage != "" { + response["header_image"] = artist.HeaderImage + } + + // Add listeners if present + if artist.Listeners > 0 { + response["listeners"] = artist.Listeners + } + + // Add top tracks if present + if len(artist.TopTracks) > 0 { + topTracks := make([]map[string]interface{}, len(artist.TopTracks)) + for i, track := range artist.TopTracks { + topTracks[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.ResolvedCoverURL(), + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "spotify_id": track.SpotifyID, + } + } + response["top_tracks"] = topTracks + } + jsonBytes, err := json.Marshal(response) if err != nil { return "", err diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 57939053..688bbf31 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -30,7 +30,7 @@ type ExtTrackMetadata struct { DiscNumber int `json:"disc_number,omitempty"` ISRC string `json:"isrc,omitempty"` ProviderID string `json:"provider_id"` - ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results + ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation // Enrichment fields from Odesli/song.link TidalID string `json:"tidal_id,omitempty"` @@ -63,11 +63,14 @@ type ExtAlbumMetadata struct { // ExtArtistMetadata represents artist metadata from an extension type ExtArtistMetadata struct { - ID string `json:"id"` - Name string `json:"name"` - ImageURL string `json:"image_url,omitempty"` - Albums []ExtAlbumMetadata `json:"albums,omitempty"` - ProviderID string `json:"provider_id"` + ID string `json:"id"` + Name string `json:"name"` + ImageURL string `json:"image_url,omitempty"` + HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background + Listeners int `json:"listeners,omitempty"` // Monthly listeners + Albums []ExtAlbumMetadata `json:"albums,omitempty"` + TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks + ProviderID string `json:"provider_id"` } // ExtSearchResult represents search results from an extension @@ -1252,6 +1255,10 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID } } + // Set provider ID on top tracks + for i := range handleResult.Artist.TopTracks { + handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID + } } return &handleResult, nil diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3cd163a3..2fafa4b0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -995,6 +995,18 @@ abstract class AppLocalizations { /// **'{count, plural, =1{1 release} other{{count} releases}}'** String artistReleases(int count); + /// Section header for popular/top tracks + /// + /// In en, this message translates to: + /// **'Popular'** + String get artistPopular; + + /// Monthly listener count display + /// + /// In en, this message translates to: + /// **'{count} monthly listeners'** + String artistMonthlyListeners(String count); + /// Track metadata screen title /// /// In en, this message translates to: @@ -3598,6 +3610,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Utility Functions'** String get utilityFunctions; + + /// Recent access item type - artist + /// + /// In en, this message translates to: + /// **'Artist'** + String get recentTypeArtist; + + /// Recent access item type - album + /// + /// In en, this message translates to: + /// **'Album'** + String get recentTypeAlbum; + + /// Recent access item type - song/track + /// + /// In en, this message translates to: + /// **'Song'** + String get recentTypeSong; + + /// Recent access item type - playlist + /// + /// In en, this message translates to: + /// **'Playlist'** + String get recentTypePlaylist; + + /// Snackbar message when tapping playlist in recent access + /// + /// In en, this message translates to: + /// **'Playlist: {name}'** + String recentPlaylistInfo(String name); + + /// Generic error message format + /// + /// In en, this message translates to: + /// **'Error: {message}'** + String errorGeneric(String message); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9ed80cb2..06e66f17 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -513,6 +513,14 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsDe extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index edc750fc..48220eb7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -513,6 +513,14 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index ffd4d0e4..2ca999d0 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -513,6 +513,14 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsEs extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9ac7a9ce..3139492a 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -513,6 +513,14 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsFr extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 9a872a1c..affa7609 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -513,6 +513,14 @@ class AppLocalizationsHi extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsHi extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 9df94cd8..5f13f1ab 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -518,6 +518,14 @@ class AppLocalizationsId extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Populer'; + + @override + String artistMonthlyListeners(String count) { + return '$count pendengar bulanan'; + } + @override String get trackMetadataTitle => 'Info Lagu'; @@ -1989,4 +1997,26 @@ class AppLocalizationsId extends AppLocalizations { @override String get utilityFunctions => 'Fungsi Utilitas'; + + @override + String get recentTypeArtist => 'Artis'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Lagu'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index e3b511ac..aaa2cc21 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -513,6 +513,14 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsJa extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index d0948473..9e8d4d22 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -513,6 +513,14 @@ class AppLocalizationsKo extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsKo extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index da893b6f..6dea9681 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -513,6 +513,14 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsNl extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4e554a25..8b423485 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -513,6 +513,14 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsPt extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 38610c5f..dee18edc 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -513,6 +513,14 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsRu extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8378c7ea..f8d7d4a4 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -513,6 +513,14 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,6 +1984,28 @@ class AppLocalizationsZh extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } /// The translations for Chinese, as used in Taiwan (`zh_TW`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6567834f..3367e863 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -362,6 +362,15 @@ "count": {"type": "int"} } }, + "artistPopular": "Popular", + "@artistPopular": {"description": "Section header for popular/top tracks"}, + "artistMonthlyListeners": "{count} monthly listeners", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": {"type": "String", "description": "Formatted listener count"} + } + }, "trackMetadataTitle": "Track Info", "@trackMetadataTitle": {"description": "Track metadata screen title"}, @@ -1446,5 +1455,29 @@ "@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"}, "utilityFunctions": "Utility Functions", - "@utilityFunctions": {"description": "Extension capability - utility functions"} + "@utilityFunctions": {"description": "Extension capability - utility functions"}, + + "recentTypeArtist": "Artist", + "@recentTypeArtist": {"description": "Recent access item type - artist"}, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": {"description": "Recent access item type - album"}, + "recentTypeSong": "Song", + "@recentTypeSong": {"description": "Recent access item type - song/track"}, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": {"description": "Recent access item type - playlist"}, + + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": {"type": "String", "description": "Playlist name"} + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": {"type": "String", "description": "Error message"} + } + } } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index b435dc00..577f7a41 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,2577 +1,681 @@ { "@@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" - } - } - }, + "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": "Language", - "@sectionLanguage": { - "description": "Settings section header for language selection" - }, - "appearanceLanguage": "App Language", - "@appearanceLanguage": { - "description": "Setting title for language selection" - }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Subtitle for language setting" - }, - "languageSystem": "System Default", - "@languageSystem": { - "description": "Use device system language" - }, + "sectionLanguage": "Bahasa", + + "appearanceLanguage": "Bahasa Aplikasi", + "appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan", + "languageSystem": "Bawaan Sistem", "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" - }, + "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" - }, "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" - } -} \ No newline at end of file + + "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", + + "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", + "recentTypeAlbum": "Album", + "recentTypeSong": "Lagu", + "recentTypePlaylist": "Playlist", + + "recentPlaylistInfo": "Playlist: {name}", + "errorGeneric": "Error: {message}" +} diff --git a/lib/l10n/supported_locales.dart b/lib/l10n/supported_locales.dart new file mode 100644 index 00000000..65a59b24 --- /dev/null +++ b/lib/l10n/supported_locales.dart @@ -0,0 +1,24 @@ +// GENERATED FILE - DO NOT EDIT +// Generated by: dart run tool/check_translations.dart 70 +// Only languages with >= 70% translation completion are included. +// Translation is measured by comparing VALUES (not just key existence). +// +// To regenerate, run: dart run tool/check_translations.dart 70 + +import 'package:flutter/widgets.dart'; + +/// Minimum translation completion threshold used to filter languages. +const int translationThreshold = 70; + +/// List of locales that meet the translation threshold. +/// Only these languages will be available in the app. +const List filteredSupportedLocales = [ + Locale('en'), + Locale('id'), +]; + +/// Set of locale codes for quick lookup. +const Set filteredLocaleCodes = { + 'en', + 'id', +}; diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart new file mode 100644 index 00000000..0882b39f --- /dev/null +++ b/lib/providers/recent_access_provider.dart @@ -0,0 +1,248 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _recentAccessKey = 'recent_access_history'; +const _maxRecentItems = 20; + +/// Types of items that can be accessed +enum RecentAccessType { + artist, + album, + track, + playlist, +} + +/// Represents a recently accessed item +class RecentAccessItem { + final String id; + final String name; + final String? subtitle; // Artist name for tracks/albums, null for artists + final String? imageUrl; + final RecentAccessType type; + final DateTime accessedAt; + final String? providerId; // Extension ID or 'deezer' for built-in + + const RecentAccessItem({ + required this.id, + required this.name, + this.subtitle, + this.imageUrl, + required this.type, + required this.accessedAt, + this.providerId, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'subtitle': subtitle, + 'imageUrl': imageUrl, + 'type': type.name, + 'accessedAt': accessedAt.toIso8601String(), + 'providerId': providerId, + }; + + factory RecentAccessItem.fromJson(Map json) { + return RecentAccessItem( + id: json['id'] as String, + name: json['name'] as String, + subtitle: json['subtitle'] as String?, + imageUrl: json['imageUrl'] as String?, + type: RecentAccessType.values.firstWhere( + (e) => e.name == json['type'], + orElse: () => RecentAccessType.track, + ), + accessedAt: DateTime.parse(json['accessedAt'] as String), + providerId: json['providerId'] as String?, + ); + } + + /// Create a unique key for deduplication + String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RecentAccessItem && + runtimeType == other.runtimeType && + uniqueKey == other.uniqueKey; + + @override + int get hashCode => uniqueKey.hashCode; +} + +/// State for recent access history +class RecentAccessState { + final List items; + final bool isLoaded; + + const RecentAccessState({ + this.items = const [], + this.isLoaded = false, + }); + + RecentAccessState copyWith({ + List? items, + bool? isLoaded, + }) { + return RecentAccessState( + items: items ?? this.items, + isLoaded: isLoaded ?? this.isLoaded, + ); + } +} + +/// Provider for managing recent access history +class RecentAccessNotifier extends Notifier { + @override + RecentAccessState build() { + _loadHistory(); + return const RecentAccessState(); + } + + Future _loadHistory() async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(_recentAccessKey); + if (json != null) { + try { + final List decoded = jsonDecode(json); + final items = decoded + .map((e) => RecentAccessItem.fromJson(e as Map)) + .toList(); + state = state.copyWith(items: items, isLoaded: true); + } catch (e) { + // Invalid JSON, start fresh + state = state.copyWith(isLoaded: true); + } + } else { + state = state.copyWith(isLoaded: true); + } + } + + Future _saveHistory() async { + final prefs = await SharedPreferences.getInstance(); + final json = jsonEncode(state.items.map((e) => e.toJson()).toList()); + await prefs.setString(_recentAccessKey, json); + } + + /// Record an access to an artist + void recordArtistAccess({ + required String id, + required String name, + String? imageUrl, + String? providerId, + }) { + _recordAccess(RecentAccessItem( + id: id, + name: name, + imageUrl: imageUrl, + type: RecentAccessType.artist, + accessedAt: DateTime.now(), + providerId: providerId, + )); + } + + /// Record an access to an album + void recordAlbumAccess({ + required String id, + required String name, + String? artistName, + String? imageUrl, + String? providerId, + }) { + _recordAccess(RecentAccessItem( + id: id, + name: name, + subtitle: artistName, + imageUrl: imageUrl, + type: RecentAccessType.album, + accessedAt: DateTime.now(), + providerId: providerId, + )); + } + + /// Record an access to a track + void recordTrackAccess({ + required String id, + required String name, + String? artistName, + String? imageUrl, + String? providerId, + }) { + _recordAccess(RecentAccessItem( + id: id, + name: name, + subtitle: artistName, + imageUrl: imageUrl, + type: RecentAccessType.track, + accessedAt: DateTime.now(), + providerId: providerId, + )); + } + + /// Record an access to a playlist + void recordPlaylistAccess({ + required String id, + required String name, + String? ownerName, + String? imageUrl, + String? providerId, + }) { + _recordAccess(RecentAccessItem( + id: id, + name: name, + subtitle: ownerName, + imageUrl: imageUrl, + type: RecentAccessType.playlist, + accessedAt: DateTime.now(), + providerId: providerId, + )); + } + + void _recordAccess(RecentAccessItem item) { + // Debug log + // ignore: avoid_print + print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})'); + + // Remove any existing entry with same unique key + final updatedItems = state.items + .where((e) => e.uniqueKey != item.uniqueKey) + .toList(); + + // Add new item at the beginning + updatedItems.insert(0, item); + + // Limit to max items + if (updatedItems.length > _maxRecentItems) { + updatedItems.removeRange(_maxRecentItems, updatedItems.length); + } + + state = state.copyWith(items: updatedItems); + _saveHistory(); + + // Debug log + // ignore: avoid_print + print('[RecentAccess] Total items now: ${updatedItems.length}'); + } + + /// Remove a specific item from history + void removeItem(RecentAccessItem item) { + final updatedItems = state.items + .where((e) => e.uniqueKey != item.uniqueKey) + .toList(); + state = state.copyWith(items: updatedItems); + _saveHistory(); + } + + /// Clear all history + void clearHistory() { + state = state.copyWith(items: []); + _saveHistory(); + } +} + +/// Provider instance +final recentAccessProvider = NotifierProvider( + RecentAccessNotifier.new, +); diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index eec5e7f6..fe067e5e 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -1,10 +1,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; final _log = AppLogger('StoreProvider'); +/// Compare two semantic version strings +/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2 +int compareVersions(String v1, String v2) { + final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.'); + final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.'); + + final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length; + + for (var i = 0; i < maxLen; i++) { + final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0; + final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0; + + if (n1 < n2) return -1; + if (n1 > n2) return 1; + } + return 0; +} + /// Extension categories class StoreCategory { static const String metadata = 'metadata'; @@ -91,6 +110,12 @@ class StoreExtension { hasUpdate: json['has_update'] as bool? ?? false, ); } + + /// Check if this extension requires a higher app version than current + bool get requiresNewerApp { + if (minAppVersion == null || minAppVersion!.isEmpty) return false; + return compareVersions(minAppVersion!, AppInfo.version) > 0; + } } /// State for extension store @@ -161,6 +186,11 @@ class StoreState { return result; } + + /// Count of extensions with updates available + int get updatesAvailableCount { + return extensions.where((e) => e.hasUpdate).length; + } } /// Provider for managing extension store diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 272a8dc8..83848520 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -17,9 +17,13 @@ class TrackState { final String? artistId; final String? artistName; final String? coverUrl; + final String? headerImageUrl; // Artist header image for background + final int? monthlyListeners; // Artist monthly listeners final List? artistAlbums; // For artist page + final List? artistTopTracks; // Artist's popular tracks final List? searchArtists; // For search results final bool hasSearchText; // For back button handling + final bool isShowingRecentAccess; // For recent access mode final String? searchExtensionId; // Extension ID used for current search results const TrackState({ @@ -32,9 +36,13 @@ class TrackState { this.artistId, this.artistName, this.coverUrl, + this.headerImageUrl, + this.monthlyListeners, this.artistAlbums, + this.artistTopTracks, this.searchArtists, this.hasSearchText = false, + this.isShowingRecentAccess = false, this.searchExtensionId, }); @@ -50,9 +58,13 @@ class TrackState { String? artistId, String? artistName, String? coverUrl, + String? headerImageUrl, + int? monthlyListeners, List? artistAlbums, + List? artistTopTracks, List? searchArtists, bool? hasSearchText, + bool? isShowingRecentAccess, String? searchExtensionId, }) { return TrackState( @@ -65,9 +77,13 @@ class TrackState { artistId: artistId ?? this.artistId, artistName: artistName ?? this.artistName, coverUrl: coverUrl ?? this.coverUrl, + headerImageUrl: headerImageUrl ?? this.headerImageUrl, + monthlyListeners: monthlyListeners ?? this.monthlyListeners, artistAlbums: artistAlbums ?? this.artistAlbums, + artistTopTracks: artistTopTracks ?? this.artistTopTracks, searchArtists: searchArtists ?? this.searchArtists, hasSearchText: hasSearchText ?? this.hasSearchText, + isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess, searchExtensionId: searchExtensionId, ); } @@ -171,13 +187,21 @@ class TrackNotifier extends Notifier { final artistData = result['artist'] as Map; final albumsList = artistData['albums'] as List? ?? []; final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + + // Parse top tracks if available + final topTracksList = artistData['top_tracks'] as List? ?? []; + final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map, source: extensionId)).toList(); + state = TrackState( tracks: [], isLoading: false, artistId: artistData['id'] as String?, artistName: artistData['name'] as String?, coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?, + headerImageUrl: artistData['header_image'] as String?, + monthlyListeners: artistData['listeners'] as int?, artistAlbums: albums, + artistTopTracks: topTracks.isNotEmpty ? topTracks : null, searchExtensionId: extensionId, ); return; @@ -491,6 +515,11 @@ class TrackNotifier extends Notifier { state = state.copyWith(hasSearchText: hasText); } + /// Set recent access mode state + void setShowingRecentAccess(bool showing) { + state = state.copyWith(isShowingRecentAccess: showing); + } + /// Set tracks from a collection (album/playlist) opened from search results void setTracksFromCollection({ required List tracks, diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f221e796..c58f7b78 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -63,6 +64,19 @@ class _AlbumScreenState extends ConsumerState { @override void initState() { super.initState(); + + // Record access for recent history + WidgetsBinding.instance.addPostFrameCallback((_) { + final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'; + ref.read(recentAccessProvider.notifier).recordAlbumAccess( + id: widget.albumId, + name: widget.albumName, + artistName: widget.tracks?.firstOrNull?.artistName, + imageUrl: widget.coverUrl, + providerId: providerId, + ); + }); + // Priority: widget.tracks > cache > fetch _tracks = widget.tracks ?? _AlbumCache.get(widget.albumId); if (_tracks == null) { diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index f60b162b..d16b5008 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,52 +1,87 @@ +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:intl/intl.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; -/// Simple in-memory cache for artist discography +/// Simple in-memory cache for artist data class _ArtistCache { static final Map _cache = {}; static const Duration _ttl = Duration(minutes: 10); - static List? get(String artistId) { + static _CacheEntry? get(String artistId) { final entry = _cache[artistId]; if (entry == null) return null; if (DateTime.now().isAfter(entry.expiresAt)) { _cache.remove(artistId); return null; } - return entry.albums; + return entry; } - static void set(String artistId, List albums) { - _cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl)); + static void set(String artistId, { + required List albums, + List? topTracks, + String? headerImageUrl, + int? monthlyListeners, + }) { + _cache[artistId] = _CacheEntry( + albums: albums, + topTracks: topTracks, + headerImageUrl: headerImageUrl, + monthlyListeners: monthlyListeners, + expiresAt: DateTime.now().add(_ttl), + ); } } class _CacheEntry { final List albums; + final List? topTracks; + final String? headerImageUrl; + final int? monthlyListeners; final DateTime expiresAt; - _CacheEntry(this.albums, this.expiresAt); + + _CacheEntry({ + required this.albums, + this.topTracks, + this.headerImageUrl, + this.monthlyListeners, + required this.expiresAt, + }); } -/// Artist screen with Material Expressive 3 design - shows discography +/// Artist screen with Spotify-like design class ArtistScreen extends ConsumerStatefulWidget { final String artistId; final String artistName; final String? coverUrl; - final List? albums; // Optional - will fetch if null + final String? headerImageUrl; + final int? monthlyListeners; + final List? albums; + final List? topTracks; + final String? extensionId; // If set, skip fetching from Spotify/Deezer const ArtistScreen({ super.key, required this.artistId, required this.artistName, this.coverUrl, + this.headerImageUrl, + this.monthlyListeners, this.albums, + this.topTracks, + this.extensionId, }); @override @@ -56,14 +91,62 @@ class ArtistScreen extends ConsumerStatefulWidget { class _ArtistScreenState extends ConsumerState { bool _isLoadingDiscography = false; List? _albums; + List? _topTracks; + String? _headerImageUrl; + int? _monthlyListeners; String? _error; @override void initState() { super.initState(); - // Priority: widget.albums > cache > fetch - _albums = widget.albums ?? _ArtistCache.get(widget.artistId); - if (_albums == null) { + + // Record access for recent history + WidgetsBinding.instance.addPostFrameCallback((_) { + final providerId = widget.extensionId ?? + (widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify'); + ref.read(recentAccessProvider.notifier).recordArtistAccess( + id: widget.artistId, + name: widget.artistName, + imageUrl: widget.coverUrl, + providerId: providerId, + ); + }); + + // If this is an extension artist, use provided data only - don't fetch from Spotify/Deezer + if (widget.extensionId != null) { + _albums = widget.albums; + _topTracks = widget.topTracks; + _headerImageUrl = widget.headerImageUrl; + _monthlyListeners = widget.monthlyListeners; + // Extension artists don't need additional fetching + return; + } + + // Priority: widget data > cache > fetch + // But always fetch if topTracks is missing (to get popular tracks) + final cached = _ArtistCache.get(widget.artistId); + + if (widget.albums != null) { + _albums = widget.albums; + _topTracks = widget.topTracks; + _headerImageUrl = widget.headerImageUrl; + _monthlyListeners = widget.monthlyListeners; + + // If we have albums but no top tracks, fetch to get them + if (_topTracks == null || _topTracks!.isEmpty) { + _fetchDiscography(); + } + } else if (cached != null) { + _albums = cached.albums; + _topTracks = cached.topTracks; + _headerImageUrl = cached.headerImageUrl; + _monthlyListeners = cached.monthlyListeners; + + // If cache has no top tracks, fetch + if (_topTracks == null || _topTracks!.isEmpty) { + _fetchDiscography(); + } + } else { _fetchDiscography(); } } @@ -72,31 +155,60 @@ class _ArtistScreenState extends ConsumerState { setState(() => _isLoadingDiscography = true); try { List albums; + List? topTracks; + String? headerImage; + int? listeners; // Check if this is a Deezer artist ID (format: "deezer:123456") if (widget.artistId.startsWith('deezer:')) { final deezerArtistId = widget.artistId.replaceFirst('deezer:', ''); - // ignore: avoid_print - print('[ArtistScreen] Fetching from Deezer: $deezerArtistId'); final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId); final albumsList = metadata['albums'] as List; albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); } else { - // Spotify artist - use fallback method - // ignore: avoid_print - print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}'); + // Spotify artist - use extension handler via URL final url = 'https://open.spotify.com/artist/${widget.artistId}'; - final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); - final albumsList = metadata['albums'] as List; - albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + final result = await PlatformBridge.handleURLWithExtension(url); + + if (result != null && result['artist'] != null) { + final artistData = result['artist'] as Map; + final albumsList = artistData['albums'] as List? ?? []; + albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + + // Parse top tracks if available + final topTracksList = artistData['top_tracks'] as List? ?? []; + if (topTracksList.isNotEmpty) { + topTracks = topTracksList.map((t) => _parseTrack(t as Map)).toList(); + } + + headerImage = artistData['header_image'] as String?; + listeners = artistData['listeners'] as int?; + } else { + // Fallback to Spotify API metadata + final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); + final albumsList = metadata['albums'] as List; + albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + } } - // Store in cache - _ArtistCache.set(widget.artistId, albums); + // Store in cache (preserve existing values if new ones are null) + final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl; + final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners; + + _ArtistCache.set( + widget.artistId, + albums: albums, + topTracks: topTracks, + headerImageUrl: finalHeaderImage, + monthlyListeners: finalListeners, + ); if (mounted) { setState(() { _albums = albums; + _topTracks = topTracks; + _headerImageUrl = finalHeaderImage; + _monthlyListeners = finalListeners; _isLoadingDiscography = false; }); } @@ -110,15 +222,41 @@ class _ArtistScreenState extends ConsumerState { } } + Track _parseTrack(Map data) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['spotify_id'] ?? data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? data['album'] ?? '').toString(), + albumArtist: data['album_artist']?.toString(), + coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date']?.toString(), + source: data['provider_id']?.toString(), + ); + } + ArtistAlbum _parseArtistAlbum(Map data) { return ArtistAlbum( id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', releaseDate: data['release_date'] as String? ?? '', totalTracks: data['total_tracks'] as int? ?? 0, - coverUrl: data['images'] as String?, + coverUrl: (data['cover_url'] ?? data['images'])?.toString(), albumType: data['album_type'] as String? ?? 'album', artists: data['artists'] as String? ?? '', + providerId: data['provider_id']?.toString(), ); } @@ -131,43 +269,63 @@ class _ArtistScreenState extends ConsumerState { final compilations = albums.where((a) => a.albumType == 'compilation').toList(); return Scaffold( - body: Stack( - children: [ - CustomScrollView( - slivers: [ - _buildAppBar(context, colorScheme), - _buildInfoCard(context, colorScheme), - if (_isLoadingDiscography) - const SliverToBoxAdapter(child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), - )), - if (_error != null) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.all(16), - child: _buildErrorWidget(_error!, colorScheme), - )), - if (!_isLoadingDiscography && _error == null) ...[ - if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)), - if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)), - if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)), - ], - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], - ), + body: CustomScrollView( + slivers: [ + _buildHeader(context, colorScheme), + if (_isLoadingDiscography) + const SliverToBoxAdapter(child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + )), + if (_error != null) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.all(16), + child: _buildErrorWidget(_error!, colorScheme), + )), + if (!_isLoadingDiscography && _error == null) ...[ + // Popular tracks section + if (_topTracks != null && _topTracks!.isNotEmpty) + SliverToBoxAdapter(child: _buildPopularSection(colorScheme)), + // Discography sections + if (albumsOnly.isNotEmpty) + SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)), + if (singles.isNotEmpty) + SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)), + if (compilations.isNotEmpty) + SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)), + ], + const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), ); } - Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { - // Validate image URL - must be non-null, non-empty, and have a valid host - final hasValidImage = widget.coverUrl != null && - widget.coverUrl!.isNotEmpty && - Uri.tryParse(widget.coverUrl!)?.hasAuthority == true; + /// Build Spotify-style header with full-width image and artist name overlay + Widget _buildHeader(BuildContext context, ColorScheme colorScheme) { + // Use header image if available, otherwise fall back to cover URL + // Prefer: fetched header > widget header > widget cover + String? imageUrl = _headerImageUrl; + if (imageUrl == null || imageUrl.isEmpty) { + imageUrl = widget.headerImageUrl; + } + if (imageUrl == null || imageUrl.isEmpty) { + imageUrl = widget.coverUrl; + } + + final hasValidImage = imageUrl != null && + imageUrl.isNotEmpty && + Uri.tryParse(imageUrl)?.hasAuthority == true; + + // Format monthly listeners + String? listenersText; + final listeners = _monthlyListeners ?? widget.monthlyListeners; + if (listeners != null && listeners > 0) { + final formatter = NumberFormat.compact(); + listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners)); + } return SliverAppBar( - expandedHeight: 280, + expandedHeight: 380, pinned: true, stretch: true, backgroundColor: colorScheme.surface, @@ -176,49 +334,84 @@ class _ArtistScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ + // Background image - full width, no circular crop if (hasValidImage) CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - color: Colors.black.withValues(alpha: 0.5), - colorBlendMode: BlendMode.darken, - memCacheWidth: 600, - errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest), + imageUrl: imageUrl, + fit: BoxFit.cover, + alignment: Alignment.topCenter, // Show top of image (faces) + memCacheWidth: 800, + placeholder: (context, url) => Container( + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant), + ), + ) + else + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant), ), + // Gradient overlay for text readability 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], + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.3), + Colors.black.withValues(alpha: 0.7), + colorScheme.surface, + ], + stops: const [0.0, 0.5, 0.75, 1.0], ), ), ), - Center( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Container( - width: 140, - height: 140, - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))], + // Artist name and listeners at bottom + Positioned( + left: 16, + right: 16, + bottom: 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.artistName, + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 4, + color: Colors.black.withValues(alpha: 0.5), + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - child: ClipOval( - child: hasValidImage - ? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: 280, - errorWidget: (context, url, error) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant), - ), - ) - : Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)), - ), - ), + if (listenersText != null) ...[ + const SizedBox(height: 4), + Text( + listenersText, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withValues(alpha: 0.8), + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 2, + color: Colors.black.withValues(alpha: 0.5), + ), + ], + ), + ), + ], + ], ), ), ], @@ -226,44 +419,280 @@ class _ArtistScreenState extends ConsumerState { 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: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.arrow_back, color: Colors.white), + ), onPressed: () => Navigator.pop(context), ), ); } - Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)), - const SizedBox(height: 8), - if (_albums != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer), - const SizedBox(width: 4), - Text(context.l10n.artistReleases(_albums!.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), - ], - ), - ), - ], + /// Build Popular tracks section like Spotify + Widget _buildPopularSection(ColorScheme colorScheme) { + if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink(); + + // Show max 5 tracks + final tracks = _topTracks!.take(5).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), + child: Text( + context.l10n.artistPopular, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, ), ), ), + ...tracks.asMap().entries.map((entry) { + final index = entry.key; + final track = entry.value; + return _buildPopularTrackItem(index + 1, track, colorScheme); + }), + ], + ); + } + + /// Build a single popular track item with dynamic download status + Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { + // Watch download queue for this track's status + final queueItem = ref.watch(downloadQueueProvider.select((state) { + return state.items.where((item) => item.track.id == track.id).firstOrNull; + })); + + // Check if track is in history (already downloaded before) + final isInHistory = ref.watch(downloadHistoryProvider.select((state) { + return state.isDownloaded(track.id); + })); + + final isQueued = queueItem != null; + final isDownloading = queueItem?.status == DownloadStatus.downloading; + final isFinalizing = queueItem?.status == DownloadStatus.finalizing; + final isCompleted = queueItem?.status == DownloadStatus.completed; + final progress = queueItem?.progress ?? 0.0; + + // Show as downloaded if in queue completed OR in history + final showAsDownloaded = isCompleted || (!isQueued && isInHistory); + + return InkWell( + onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // Rank number + SizedBox( + width: 24, + child: Text( + '$rank', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + // Album art + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: track.coverUrl != null + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + memCacheWidth: 96, + placeholder: (context, url) => Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24), + ), + ) + : Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24), + ), + ), + const SizedBox(width: 12), + // Track info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (track.albumName.isNotEmpty) + Text( + track.albumName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Download button with status + _buildPopularDownloadButton( + track: track, + colorScheme: colorScheme, + isQueued: isQueued, + isDownloading: isDownloading, + isFinalizing: isFinalizing, + showAsDownloaded: showAsDownloaded, + isInHistory: isInHistory, + progress: progress, + ), + ], + ), + ), + ); + } + + /// Handle tap on popular track item + void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory}) async { + if (isQueued) return; + + if (isInHistory) { + final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); + if (historyItem != null) { + final fileExists = await File(historyItem.filePath).exists(); + if (fileExists) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))), + ); + } + return; + } else { + ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); + } + } + } + + _downloadTrack(track); + } + + /// Build download button with status indicator for popular tracks + Widget _buildPopularDownloadButton({ + required Track track, + required ColorScheme colorScheme, + required bool isQueued, + required bool isDownloading, + required bool isFinalizing, + required bool showAsDownloaded, + required bool isInHistory, + required double progress, + }) { + const double size = 40.0; + const double iconSize = 20.0; + + if (showAsDownloaded) { + return GestureDetector( + onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize), + ), + ); + } else if (isFinalizing) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + strokeWidth: 2.5, + color: colorScheme.tertiary, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14), + ], + ), + ); + } else if (isDownloading) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: progress > 0 ? progress : null, + strokeWidth: 2.5, + color: colorScheme.primary, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + if (progress > 0) + Text( + '${(progress * 100).toInt()}', + style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: colorScheme.primary), + ), + ], + ), + ); + } else if (isQueued) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize), + ); + } else { + return GestureDetector( + onTap: () => _downloadTrack(track), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + shape: BoxShape.circle, + ), + child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize), + ), + ); + } + } + + void _downloadTrack(Track track) { + final settings = ref.read(settingsProvider); + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + duration: const Duration(seconds: 2), ), ); } @@ -273,24 +702,26 @@ class _ArtistScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: Row( - children: [ - Icon(Icons.album, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)), - ], + padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), + child: Text( + '$title (${albums.length})', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), ), SizedBox( - height: 210, + height: 220, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), itemCount: albums.length, itemBuilder: (context, index) { final album = albums[index]; - return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme)); + return KeyedSubtree( + key: ValueKey(album.id), + child: _buildAlbumCard(album, colorScheme), + ); }, ), ), @@ -303,55 +734,71 @@ class _ArtistScreenState extends ConsumerState { onTap: () => _navigateToAlbum(album), child: Container( width: 140, - margin: const EdgeInsets.symmetric(horizontal: 6), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: album.coverUrl != null - ? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248) - : Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)), - ), - const SizedBox(height: 6), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis), - const Spacer(), - Text( - album.totalTracks > 0 - ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${context.l10n.tracksCount(album.totalTracks)}' - : album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11), - maxLines: 1, - overflow: TextOverflow.ellipsis, + margin: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Album cover + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, + width: 140, + height: 140, + fit: BoxFit.cover, + memCacheWidth: 280, + placeholder: (context, url) => Container( + width: 140, + height: 140, + color: colorScheme.surfaceContainerHighest, ), - ], - ), - ), - ], + errorWidget: (context, url, error) => Container( + width: 140, + height: 140, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40), + ), + ) + : Container( + width: 140, + height: 140, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40), + ), ), - ), + const SizedBox(height: 8), + // Album name + Text( + album.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + // Year and track count + Text( + album.totalTracks > 0 + ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' + : album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ); } void _navigateToAlbum(ArtistAlbum album) { - // Navigate immediately with data from artist discography, fetch tracks in AlbumScreen ref.read(settingsProvider.notifier).setHasSearchedBefore(); - // Check if this album is from an extension (has providerId) if (album.providerId != null && album.providerId!.isNotEmpty) { - // Use ExtensionAlbumScreen for extension albums Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: album.providerId!, @@ -361,19 +808,16 @@ class _ArtistScreenState extends ConsumerState { ), )); } else { - // Use regular AlbumScreen for Spotify/Deezer albums Navigator.push(context, MaterialPageRoute( builder: (context) => AlbumScreen( albumId: album.id, albumName: album.name, coverUrl: album.coverUrl, - // tracks: null - will be fetched in AlbumScreen ), )); } } - /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -383,7 +827,7 @@ class _ArtistScreenState extends ConsumerState { return Card( elevation: 0, color: colorScheme.errorContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(16), child: Row( @@ -418,11 +862,10 @@ class _ArtistScreenState extends ConsumerState { ); } - // Default error display return Card( elevation: 0, color: colorScheme.errorContainer.withValues(alpha: 0.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(16), child: Row( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 55e76f79..960224a5 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; @@ -38,16 +39,27 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient void initState() { super.initState(); _urlController.addListener(_onSearchChanged); + _searchFocusNode.addListener(_onSearchFocusChanged); } @override void dispose() { _urlController.removeListener(_onSearchChanged); + _searchFocusNode.removeListener(_onSearchFocusChanged); _urlController.dispose(); _searchFocusNode.dispose(); super.dispose(); } + void _onSearchFocusChanged() { + // When focused, enter recent access mode + // When unfocused (keyboard dismissed), keep recent access mode visible + // User must press back button to exit recent access mode + if (_searchFocusNode.hasFocus) { + ref.read(trackProvider.notifier).setShowingRecentAccess(true); + } + } + /// Called when trackState changes - used to sync search bar with state void _onTrackStateChanged(TrackState? previous, TrackState next) { // If state was cleared (no content, no search text, not loading), clear the search bar @@ -147,7 +159,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient void _navigateToDetailIfNeeded() { final trackState = ref.read(trackProvider); - // Navigate to Album screen + // Navigate to Album screen (recording is done in AlbumScreen.initState) if (trackState.albumId != null && trackState.albumName != null && trackState.tracks.isNotEmpty) { Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen( albumId: trackState.albumId!, @@ -163,6 +175,14 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // Navigate to Playlist screen if (trackState.playlistName != null && trackState.tracks.isNotEmpty) { + // Record access for playlist (no separate screen to record in) + ref.read(recentAccessProvider.notifier).recordPlaylistAccess( + id: trackState.playlistName!, + name: trackState.playlistName!, + imageUrl: trackState.coverUrl, + providerId: 'spotify', + ); + Navigator.push(context, MaterialPageRoute(builder: (context) => PlaylistScreen( playlistName: trackState.playlistName!, coverUrl: trackState.coverUrl, @@ -174,7 +194,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return; } - // Navigate to Artist screen + // Navigate to Artist screen (recording is done in ArtistScreen.initState) if (trackState.artistId != null && trackState.artistName != null && trackState.artistAlbums != null) { Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen( artistId: trackState.artistId!, @@ -271,20 +291,23 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (!mounted) return; + // ignore: use_build_context_synchronously + final l10n = context.l10n; + // Optionally show confirmation dialog final confirmed = await showDialog( context: this.context, builder: (dialogCtx) => AlertDialog( - title: Text(context.l10n.dialogImportPlaylistTitle), - content: Text(context.l10n.dialogImportPlaylistMessage(tracks.length)), + title: Text(l10n.dialogImportPlaylistTitle), + content: Text(l10n.dialogImportPlaylistMessage(tracks.length)), actions: [ TextButton( onPressed: () => Navigator.pop(dialogCtx, false), - child: Text(context.l10n.dialogCancel), + child: Text(l10n.dialogCancel), ), FilledButton( onPressed: () => Navigator.pop(dialogCtx, true), - child: Text(context.l10n.dialogImport), + child: Text(l10n.dialogImport), ), ], ), @@ -295,9 +318,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (mounted) { ScaffoldMessenger.of(this.context).showSnackBar( SnackBar( - content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), + content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), action: SnackBarAction( - label: context.l10n.snackbarViewQueue, + label: l10n.snackbarViewQueue, onPressed: () { // Navigate to queue tab (handled by main_shell index) // We don't have direct access to set index here easily without provider @@ -337,39 +360,62 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.watch(extensionProvider.select((s) => s.extensions)); final colorScheme = Theme.of(context).colorScheme; - final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading; + final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty); + final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess)); + // Move search bar up when in recent access mode or has results + final hasResults = isShowingRecentAccess || hasActualResults || isLoading; final screenHeight = MediaQuery.of(context).size.height; final topPadding = MediaQuery.of(context).padding.top; final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); + final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items)); + + // Show recent access when in mode but no actual results yet (includes download history) + final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty; + final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading; + + // Exit recent access mode when results appear + if (hasActualResults && isShowingRecentAccess) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false); + }); + } - return Scaffold( - body: CustomScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ - // App Bar - always present - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - automaticallyImplyLeading: false, - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); - - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: const EdgeInsets.only(left: 24, bottom: 16), - title: Text( - context.l10n.homeTitle, - style: TextStyle( - fontSize: 20 + (14 * expandRatio), // 20 -> 34 - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + return GestureDetector( + onTap: () { + // Unfocus search bar when tapping outside + if (_searchFocusNode.hasFocus) { + _searchFocusNode.unfocus(); + } + }, + behavior: HitTestBehavior.translucent, + child: Scaffold( + body: CustomScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + // App Bar - always present + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: const EdgeInsets.only(left: 24, bottom: 16), + title: Text( + context.l10n.homeTitle, + style: TextStyle( + fontSize: 20 + (14 * expandRatio), // 20 -> 34 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, ), ), ); @@ -438,12 +484,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), + // Recent access history - shown when in recent access mode (persists after keyboard dismissed) + // User can exit by pressing back button + if (showRecentAccess) + SliverToBoxAdapter( + child: _buildRecentAccess(recentAccessItems, colorScheme), + ), + // Idle content below search bar - always in tree SliverToBoxAdapter( child: AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, - child: hasResults + child: (hasResults || showRecentAccess) ? const SizedBox.shrink() : Column( children: [ @@ -479,7 +532,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ], ), - ); + ), // Close GestureDetector + ); } Widget _buildRecentDownloads(List items, ColorScheme colorScheme) { @@ -553,6 +607,224 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } + /// Build recent access history section (shown when search focused) + Widget _buildRecentAccess(List items, ColorScheme colorScheme) { + // Merge with recent downloads to make the list more populated + final historyItems = ref.read(downloadHistoryProvider).items; + + // Convert download history to RecentAccessItem format + 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, + imageUrl: h.coverUrl, + type: RecentAccessType.track, + accessedAt: h.downloadedAt, + providerId: 'download', + )).toList(); + + // Merge and sort by accessedAt (most recent first) + final allItems = [...items, ...downloadItems]; + allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + // Remove duplicates (keep the most recent one) + final seen = {}; + final uniqueItems = allItems.where((item) { + final key = '${item.type.name}:${item.id}'; + if (seen.contains(key)) return false; + seen.add(key); + return true; + }).take(10).toList(); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with clear button + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.homeRecent, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + TextButton( + onPressed: () { + ref.read(recentAccessProvider.notifier).clearHistory(); + }, + child: Text( + context.l10n.dialogClearAll, + style: TextStyle(color: colorScheme.primary, fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 8), + // List of recent items + ...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)), + ], + ), + ); + } + + Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) { + // Icon and label based on type + IconData typeIcon; + String typeLabel; + switch (item.type) { + case RecentAccessType.artist: + typeIcon = Icons.person; + typeLabel = context.l10n.recentTypeArtist; + case RecentAccessType.album: + typeIcon = Icons.album; + typeLabel = context.l10n.recentTypeAlbum; + case RecentAccessType.track: + typeIcon = Icons.music_note; + typeLabel = context.l10n.recentTypeSong; + case RecentAccessType.playlist: + typeIcon = Icons.playlist_play; + typeLabel = context.l10n.recentTypePlaylist; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: InkWell( + onTap: () => _navigateToRecentItem(item), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row( + children: [ + // Image + ClipRRect( + borderRadius: BorderRadius.circular(item.type == RecentAccessType.artist ? 28 : 4), + child: item.imageUrl != null && item.imageUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.imageUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + errorWidget: (context, url, error) => Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(typeIcon, color: colorScheme.onSurfaceVariant), + ), + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(typeIcon, color: colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(width: 12), + // Text content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + item.subtitle != null ? '$typeLabel • ${item.subtitle}' : typeLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Delete button (like Spotify's X) + IconButton( + icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant), + onPressed: () { + ref.read(recentAccessProvider.notifier).removeItem(item); + }, + ), + ], + ), + ), + ), + ); + } + + void _navigateToRecentItem(RecentAccessItem item) { + _searchFocusNode.unfocus(); + + switch (item.type) { + case RecentAccessType.artist: + // Check if artist is from extension (not spotify/deezer) + if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') { + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionArtistScreen( + extensionId: item.providerId!, + artistId: item.id, + artistName: item.name, + coverUrl: item.imageUrl, + ), + )); + } else { + Navigator.push(context, MaterialPageRoute( + builder: (context) => ArtistScreen( + artistId: item.id, + artistName: item.name, + coverUrl: item.imageUrl, + ), + )); + } + case RecentAccessType.album: + if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') { + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: item.providerId!, + albumId: item.id, + albumName: item.name, + coverUrl: item.imageUrl, + ), + )); + } else { + Navigator.push(context, MaterialPageRoute( + builder: (context) => AlbumScreen( + albumId: item.id, + albumName: item.name, + coverUrl: item.imageUrl, + ), + )); + } + case RecentAccessType.track: + // For tracks from download history, navigate to metadata screen + final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(item.id); + if (historyItem != null) { + _navigateToMetadataScreen(historyItem); + } else { + // Track not in history anymore + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(item.name)), + ); + } + case RecentAccessType.playlist: + // Playlist needs tracks, so we just show info + // Could potentially re-fetch using URL handler if we stored URL + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), + ); + } + } + void _navigateToMetadataScreen(DownloadHistoryItem item) { Navigator.push(context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), @@ -888,6 +1160,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient void _navigateToArtist(String artistId, String artistName, String? imageUrl) { // Navigate immediately with data from search, fetch albums in ArtistScreen ref.read(settingsProvider.notifier).setHasSearchedBefore(); + + // Recording is done in ArtistScreen.initState to avoid duplicates + Navigator.push(context, MaterialPageRoute( builder: (context) => ArtistScreen( artistId: artistId, @@ -909,6 +1184,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); + // Record access for recent history + ref.read(recentAccessProvider.notifier).recordAlbumAccess( + id: albumItem.id, + name: albumItem.name, + artistName: albumItem.artistName, + imageUrl: albumItem.coverUrl, + providerId: extensionId, + ); + // Navigate to AlbumScreen - it will fetch tracks via extension Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( @@ -931,6 +1215,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); + // Record access for recent history + ref.read(recentAccessProvider.notifier).recordPlaylistAccess( + id: playlistItem.id, + name: playlistItem.name, + ownerName: playlistItem.artistName, + imageUrl: playlistItem.coverUrl, + providerId: extensionId, + ); + // Navigate to ExtensionPlaylistScreen - it will fetch tracks via extension Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( @@ -953,6 +1246,14 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); + // Record access for recent history + ref.read(recentAccessProvider.notifier).recordArtistAccess( + id: artistItem.id, + name: artistItem.name, + imageUrl: artistItem.coverUrl, + providerId: extensionId, + ); + // Navigate to ExtensionArtistScreen - it will fetch albums via extension Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionArtistScreen( @@ -1687,6 +1988,9 @@ class ExtensionArtistScreen extends ConsumerStatefulWidget { class _ExtensionArtistScreenState extends ConsumerState { List? _albums; + List? _topTracks; + String? _headerImageUrl; + int? _monthlyListeners; bool _isLoading = true; String? _error; @@ -1719,18 +2023,24 @@ class _ExtensionArtistScreenState extends ConsumerState { // Parse albums from result final albumList = result['albums'] as List?; - if (albumList == null) { - setState(() { - _albums = []; - _isLoading = false; - }); - return; + final albums = albumList?.map((a) => _parseAlbum(a as Map)).toList() ?? []; + + // Parse top tracks from result + final topTracksList = result['top_tracks'] as List?; + List? topTracks; + if (topTracksList != null && topTracksList.isNotEmpty) { + topTracks = topTracksList.map((t) => _parseTrack(t as Map)).toList(); } - final albums = albumList.map((a) => _parseAlbum(a as Map)).toList(); + // Parse additional artist info + final headerImage = result['header_image'] as String?; + final listeners = result['listeners'] as int?; setState(() { _albums = albums; + _topTracks = topTracks; + _headerImageUrl = headerImage; + _monthlyListeners = listeners; _isLoading = false; }); } catch (e) { @@ -1755,6 +2065,31 @@ class _ExtensionArtistScreenState extends ConsumerState { ); } + Track _parseTrack(Map data) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['id'] ?? data['spotify_id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? data['album'] ?? '').toString(), + albumArtist: data['album_artist']?.toString(), + coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date']?.toString(), + source: (data['provider_id'] ?? widget.extensionId).toString(), + ); + } + @override Widget build(BuildContext context) { if (_isLoading) { @@ -1780,12 +2115,16 @@ class _ExtensionArtistScreenState extends ConsumerState { ); } - // Navigate to ArtistScreen with fetched albums + // Navigate to ArtistScreen with fetched albums and top tracks return ArtistScreen( artistId: widget.artistId, artistName: widget.artistName, coverUrl: widget.coverUrl, + headerImageUrl: _headerImageUrl, + monthlyListeners: _monthlyListeners, albums: _albums, + topTracks: _topTracks, + extensionId: widget.extensionId, // Skip Spotify/Deezer fetch ); } } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index edd2d226..65a72da3 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/screens/home_tab.dart'; import 'package:spotiflac_android/screens/store_tab.dart'; @@ -124,7 +125,8 @@ class _MainShellState extends ConsumerState { if (_currentIndex != index) { setState(() => _currentIndex = index); // Unfocus any text field when switching tabs to prevent keyboard from appearing - FocusScope.of(context).unfocus(); + // Use primaryFocus for more aggressive unfocus that works with keep-alive widgets + FocusManager.instance.primaryFocus?.unfocus(); } } @@ -135,7 +137,15 @@ class _MainShellState extends ConsumerState { // Check if keyboard is visible - if so, just dismiss keyboard, don't clear search final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; if (isKeyboardVisible) { - FocusScope.of(context).unfocus(); + FocusManager.instance.primaryFocus?.unfocus(); + return; + } + + // If on Home tab and showing recent access mode, exit it + if (_currentIndex == 0 && trackState.isShowingRecentAccess) { + ref.read(trackProvider.notifier).setShowingRecentAccess(false); + // Also unfocus search bar when exiting recent access mode + FocusManager.instance.primaryFocus?.unfocus(); return; } @@ -177,6 +187,7 @@ class _MainShellState extends ConsumerState { final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final trackState = ref.watch(trackProvider); final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore)); + final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount)); // Check if keyboard is visible (bottom inset > 0 means keyboard is showing) final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; @@ -188,6 +199,7 @@ class _MainShellState extends ConsumerState { !trackState.hasSearchText && !trackState.hasContent && !trackState.isLoading && + !trackState.isShowingRecentAccess && !isKeyboardVisible; // Build tabs and destinations based on settings @@ -224,8 +236,16 @@ class _MainShellState extends ConsumerState { ), if (showStore) NavigationDestination( - icon: const Icon(Icons.store_outlined), - selectedIcon: const Icon(Icons.store), + icon: Badge( + isLabelVisible: storeUpdatesCount > 0, + label: Text('$storeUpdatesCount'), + child: const Icon(Icons.store_outlined), + ), + selectedIcon: Badge( + isLabelVisible: storeUpdatesCount > 0, + label: Text('$storeUpdatesCount'), + child: const Icon(Icons.store), + ), label: l10n.navStore, ), NavigationDestination( diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 8646d647..ac1f55bf 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/l10n/supported_locales.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -709,7 +710,8 @@ class _LanguageSelector extends StatelessWidget { required this.onChanged, }); - static const _languages = [ + // All available languages (code, displayName, icon) + static const _allLanguages = [ ('system', 'System Default', Icons.phone_android), ('en', 'English', Icons.language), ('id', 'Bahasa Indonesia', Icons.language), @@ -726,8 +728,20 @@ class _LanguageSelector extends StatelessWidget { ('zh_TW', '繁體中文', Icons.language), ]; + /// Get only languages that meet the translation threshold. + /// Uses filteredLocaleCodes from supported_locales.dart (generated file). + List<(String, String, IconData)> get _languages { + return _allLanguages.where((lang) { + // Always include 'system' option + if (lang.$1 == 'system') return true; + // Only include languages in the filtered set + return filteredLocaleCodes.contains(lang.$1); + }).toList(); + } + String _getLanguageName(String code) { - for (final lang in _languages) { + // Search in all languages (not just filtered) for display name fallback + for (final lang in _allLanguages) { if (lang.$1 == code) return lang.$2; } return code; diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 835ca93c..b6421a8d 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -127,6 +127,9 @@ class SettingsTab extends ConsumerWidget { } void _navigateTo(BuildContext context, Widget page) { + // Unfocus any focused widget before navigating to prevent keyboard from appearing on return + FocusManager.instance.primaryFocus?.unfocus(); + Navigator.of(context).push( // Use PageRouteBuilder for better predictive back gesture support // MaterialPageRoute can cause freeze on some devices with gesture navigation diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index b5ecad78..eab625cb 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -548,15 +548,41 @@ class _ExtensionItem extends StatelessWidget { color: colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: 4), - Text( - extension.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + // Warning badge for incompatible extensions + if (extension.requiresNewerApp) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer), + const SizedBox(width: 4), + Text( + 'Requires v${extension.minAppVersion}+', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + ] else ...[ + const SizedBox(height: 4), + Text( + extension.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ], ), ),