From 611abdc6aeaf18f75667cc649b3297f576bb98bb Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 29 Apr 2026 18:33:44 +0700 Subject: [PATCH] feat: improve extension metadata UI --- go_backend/exports.go | 76 +++--- go_backend/extension_providers.go | 3 + go_backend/extension_runtime_auth.go | 2 +- go_backend/extension_runtime_file.go | 272 +++++++++++++++++++++- go_backend/extension_runtime_polyfills.go | 2 +- lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_de.dart | 10 + lib/l10n/app_localizations_en.dart | 10 + lib/l10n/app_localizations_es.dart | 10 + lib/l10n/app_localizations_fr.dart | 10 + lib/l10n/app_localizations_hi.dart | 10 + lib/l10n/app_localizations_id.dart | 10 + lib/l10n/app_localizations_ja.dart | 10 + lib/l10n/app_localizations_ko.dart | 10 + lib/l10n/app_localizations_nl.dart | 10 + lib/l10n/app_localizations_pt.dart | 10 + lib/l10n/app_localizations_ru.dart | 10 + lib/l10n/app_localizations_tr.dart | 10 + lib/l10n/app_localizations_zh.dart | 10 + lib/l10n/arb/app_en.arb | 12 + lib/models/track.dart | 9 + lib/models/track.g.dart | 4 + lib/providers/track_provider.dart | 8 + lib/screens/album_screen.dart | 8 + lib/screens/home_tab.dart | 10 + lib/screens/playlist_screen.dart | 8 + lib/screens/search_screen.dart | 37 +-- lib/screens/setup_screen.dart | 171 +++++++++++++- lib/widgets/audio_quality_badges.dart | 134 +++++++++++ 29 files changed, 843 insertions(+), 61 deletions(-) create mode 100644 lib/widgets/audio_quality_badges.dart diff --git a/go_backend/exports.go b/go_backend/exports.go index e593a9d6..20c8548e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2047,25 +2047,27 @@ func normalizeExtensionTrackMetadataMap( } return 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": coverURL, - "cover_url": coverURL, - "release_date": track.ReleaseDate, - "track_number": trackNum, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "isrc": track.ISRC, - "provider_id": track.ProviderID, - "item_type": track.ItemType, - "album_type": track.AlbumType, - "spotify_id": track.SpotifyID, - "composer": track.Composer, + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": coverURL, + "cover_url": coverURL, + "release_date": track.ReleaseDate, + "track_number": trackNum, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "item_type": track.ItemType, + "album_type": track.AlbumType, + "spotify_id": track.SpotifyID, + "composer": track.Composer, + "audio_quality": track.AudioQuality, + "audio_modes": track.AudioModes, } } @@ -3434,23 +3436,25 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string result := make([]map[string]interface{}, len(tracks)) for i, track := range tracks { result[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, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "isrc": track.ISRC, - "provider_id": track.ProviderID, - "item_type": track.ItemType, - "album_type": track.AlbumType, - "composer": track.Composer, + "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, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "item_type": track.ItemType, + "album_type": track.AlbumType, + "composer": track.Composer, + "audio_quality": track.AudioQuality, + "audio_modes": track.AudioModes, } } diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 01072268..6a07b26e 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -43,6 +43,9 @@ type ExtTrackMetadata struct { Copyright string `json:"copyright,omitempty"` Genre string `json:"genre,omitempty"` Composer string `json:"composer,omitempty"` + + AudioQuality string `json:"audio_quality,omitempty"` + AudioModes string `json:"audio_modes,omitempty"` } func (t *ExtTrackMetadata) ResolvedCoverURL() string { diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go index afa76d52..0622ecff 100644 --- a/go_backend/extension_runtime_auth.go +++ b/go_backend/extension_runtime_auth.go @@ -461,7 +461,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja req = r.bindDownloadCancelContext(req) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") + req.Header.Set("User-Agent", appUserAgent()) resp, err := r.httpClient.Do(req) if err != nil { diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 96a55308..3c445c4f 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/dop251/goja" ) @@ -134,6 +135,8 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { var onProgress goja.Callable var headers map[string]string + var chunkedDownload bool + var chunkSize int64 if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { optionsObj := call.Arguments[2].Export() if opts, ok := optionsObj.(map[string]interface{}); ok { @@ -148,9 +151,30 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { onProgress = callable } } + if chunked, ok := opts["chunked"]; ok { + switch v := chunked.(type) { + case bool: + chunkedDownload = v + case int64: + if v > 0 { + chunkedDownload = true + chunkSize = v + } + case float64: + if v > 0 { + chunkedDownload = true + chunkSize = int64(v) + } + } + } } } + // Default chunk size: 1MB (YouTube CDN max without poToken) + if chunkedDownload && chunkSize <= 0 { + chunkSize = 1024 * 1024 + } + dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0755); err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -159,6 +183,20 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } + client := r.downloadClient + if client == nil { + client = r.httpClient + } + + ua := appUserAgent() + if h, ok := headers["User-Agent"]; ok && h != "" { + ua = h + } + + if chunkedDownload { + return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress) + } + req, err := http.NewRequest("GET", urlStr, nil) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -172,12 +210,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { req.Header.Set(k, v) } if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") - } - - client := r.downloadClient - if client == nil { - client = r.httpClient + req.Header.Set("User-Agent", appUserAgent()) } resp, err := client.Do(req) @@ -189,7 +222,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return r.vm.ToValue(map[string]interface{}{ "success": false, "error": fmt.Sprintf("HTTP error: %d", resp.StatusCode), @@ -277,6 +310,231 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } +// fileDownloadChunked downloads a URL using sequential Range requests. +// This is needed for servers (like YouTube's googlevideo CDN) that reject +// non-ranged or large-range requests with 403 and require small chunk downloads. +func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable) goja.Value { + // First, get the total content length with a small probe request + probeReq, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("chunked: probe request error: %v", err), + }) + } + probeReq = r.bindDownloadCancelContext(probeReq) + probeReq.Header.Set("User-Agent", ua) + for k, v := range headers { + if k != "Range" { // Don't copy any existing Range header + probeReq.Header.Set(k, v) + } + } + probeReq.Header.Set("Range", "bytes=0-1") + + probeResp, err := client.Do(probeReq) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("chunked: probe error: %v", err), + }) + } + io.Copy(io.Discard, probeResp.Body) + probeResp.Body.Close() + + if probeResp.StatusCode != 206 && probeResp.StatusCode != 200 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("chunked: probe HTTP %d", probeResp.StatusCode), + }) + } + + // Parse Content-Range to get total size: "bytes 0-1/TOTAL" + var totalSize int64 + contentRange := probeResp.Header.Get("Content-Range") + if contentRange != "" { + // Format: "bytes 0-1/12345" + if idx := strings.LastIndex(contentRange, "/"); idx >= 0 { + sizeStr := contentRange[idx+1:] + if sizeStr != "*" { + fmt.Sscanf(sizeStr, "%d", &totalSize) + } + } + } + + if totalSize <= 0 { + // Fallback: try Content-Length from a HEAD-like approach + // If we can't determine size, download with unknown size + GoLog("[Extension:%s] Chunked download: unknown total size, will download until server says done\n", r.extensionID) + } else { + GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize) + } + + out, err := os.Create(fullPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create file: %v", err), + }) + } + defer out.Close() + + activeItemID := r.getActiveDownloadItemID() + if activeItemID != "" { + SetItemDownloading(activeItemID) + } + + shouldTrackItemBytes := activeItemID != "" && onProgress == nil + if shouldTrackItemBytes && totalSize > 0 { + SetItemBytesTotal(activeItemID, totalSize) + } + + var progressWriter interface{ Write([]byte) (int, error) } = out + if shouldTrackItemBytes { + progressWriter = NewItemProgressWriter(out, activeItemID) + } + + var totalWritten int64 + buf := make([]byte, 32*1024) + maxRetries := 3 + + for offset := int64(0); totalSize <= 0 || offset < totalSize; { + end := offset + chunkSize - 1 + if totalSize > 0 && end >= totalSize { + end = totalSize - 1 + } + + var chunkResp *http.Response + var chunkErr error + + for retry := 0; retry < maxRetries; retry++ { + chunkReq, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("chunked: request error at offset %d: %v", offset, err), + }) + } + chunkReq = r.bindDownloadCancelContext(chunkReq) + chunkReq.Header.Set("User-Agent", ua) + for k, v := range headers { + if k != "Range" { + chunkReq.Header.Set(k, v) + } + } + chunkReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, end)) + + chunkResp, chunkErr = client.Do(chunkReq) + if chunkErr != nil { + if retry < maxRetries-1 { + time.Sleep(time.Duration(retry+1) * time.Second) + continue + } + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("chunked: error at offset %d after %d retries: %v", offset, maxRetries, chunkErr), + }) + } + + if chunkResp.StatusCode == 206 || chunkResp.StatusCode == 200 { + break // Success + } + + // Non-success status + io.Copy(io.Discard, chunkResp.Body) + chunkResp.Body.Close() + + if chunkResp.StatusCode == 403 || chunkResp.StatusCode == 429 { + if retry < maxRetries-1 { + time.Sleep(time.Duration(retry+1) * 2 * time.Second) + continue + } + } + + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("chunked: HTTP %d at offset %d", chunkResp.StatusCode, offset), + }) + } + + // Read chunk body and write to file + chunkWritten := int64(0) + for { + nr, er := chunkResp.Body.Read(buf) + if nr > 0 { + nw, ew := progressWriter.Write(buf[0:nr]) + if nw < 0 || nr < nw { + nw = 0 + if ew == nil { + ew = fmt.Errorf("invalid write result") + } + } + chunkWritten += int64(nw) + totalWritten += int64(nw) + if ew != nil { + chunkResp.Body.Close() + if ew == ErrDownloadCancelled { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "download cancelled", + }) + } + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to write file: %v", ew), + }) + } + if nr != nw { + chunkResp.Body.Close() + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "short write", + }) + } + + if onProgress != nil && totalSize > 0 { + _, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize)) + } + } + if er != nil { + if er != io.EOF { + chunkResp.Body.Close() + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to read chunk at offset %d: %v", offset, er), + }) + } + break + } + } + chunkResp.Body.Close() + + offset += chunkWritten + + // If server returned 200 (full content) instead of 206, we're done + if chunkResp.StatusCode == 200 { + break + } + + // If we got less data than expected and we know total size, check if done + if totalSize > 0 && offset >= totalSize { + break + } + + // Unknown size: if we got less than chunk size, assume done + if totalSize <= 0 && chunkWritten < chunkSize { + break + } + } + + GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath) + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullPath, + "size": totalWritten, + }) +} + func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(false) diff --git a/go_backend/extension_runtime_polyfills.go b/go_backend/extension_runtime_polyfills.go index 5f5cb4a5..1e51cde5 100644 --- a/go_backend/extension_runtime_polyfills.go +++ b/go_backend/extension_runtime_polyfills.go @@ -75,7 +75,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { req.Header.Set(k, v) } if req.Header.Get("User-Agent") == "" { - req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") + req.Header.Set("User-Agent", appUserAgent()) } if bodyStr != "" && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3cd4021e..e189f367 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1120,6 +1120,24 @@ abstract class AppLocalizations { /// **'Please enable \"Allow access to manage all files\" in the next screen.'** String get setupAllowAccessToManageFiles; + /// Title for the language selection step in setup + /// + /// In en, this message translates to: + /// **'Choose Language'** + String get setupLanguageTitle; + + /// Description for the language selection step in setup + /// + /// In en, this message translates to: + /// **'Select your preferred language for the app. You can change this later in Settings.'** + String get setupLanguageDescription; + + /// Option to use the system language + /// + /// In en, this message translates to: + /// **'System Default'** + String get setupLanguageSystemDefault; + /// Dialog button - cancel action /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2fa23d94..1c288c6f 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -570,6 +570,16 @@ class AppLocalizationsDe extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Bitte aktiviere \"Zugriff auf alle Dateien erlauben\" auf dem nächsten Bildschirm.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Abbrechen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0ccddc3b..3a7f321f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -559,6 +559,16 @@ class AppLocalizationsEn extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Cancel'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c8c330f4..1b3ba395 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -559,6 +559,16 @@ class AppLocalizationsEs extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Cancel'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 1db6578e..febc27f3 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -561,6 +561,16 @@ class AppLocalizationsFr extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Cancel'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 6ff73820..bf1d49ed 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -559,6 +559,16 @@ class AppLocalizationsHi extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Cancel'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 3454dbdd..c0af574b 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -562,6 +562,16 @@ class AppLocalizationsId extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Batal'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 3e5c144f..0cd8b269 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -554,6 +554,16 @@ class AppLocalizationsJa extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'キャンセル'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 4247c22f..d5ef074c 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -544,6 +544,16 @@ class AppLocalizationsKo extends AppLocalizations { String get setupAllowAccessToManageFiles => '다음 화면에서 \"모든 파일 관리 권한 허용\"을 활성화해 주세요.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => '취소'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ea67f5f7..91a9a831 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -559,6 +559,16 @@ class AppLocalizationsNl extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Cancel'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 8e6baa5f..a86eef76 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -559,6 +559,16 @@ class AppLocalizationsPt extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Cancel'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3412bd6f..fba0594c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -568,6 +568,16 @@ class AppLocalizationsRu extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Пожалуйста, включите \"Разрешить доступ для управления всеми файлами\" на следующем экране.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Отмена'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index e3a57b79..a83ccb41 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -570,6 +570,16 @@ class AppLocalizationsTr extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Lütfen sonraki ekranda \"Tüm dosyaları yönetme erişimine izin ver\" seçeneğini açın.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'İptal'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1e38d36a..a68ed687 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -559,6 +559,16 @@ class AppLocalizationsZh extends AppLocalizations { String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; + @override + String get setupLanguageTitle => 'Choose Language'; + + @override + String get setupLanguageDescription => + 'Select your preferred language for the app. You can change this later in Settings.'; + + @override + String get setupLanguageSystemDefault => 'System Default'; + @override String get dialogCancel => 'Cancel'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d5ee9469..b4635b68 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -707,6 +707,18 @@ "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, + "setupLanguageTitle": "Choose Language", + "@setupLanguageTitle": { + "description": "Title for the language selection step in setup" + }, + "setupLanguageDescription": "Select your preferred language for the app. You can change this later in Settings.", + "@setupLanguageDescription": { + "description": "Description for the language selection step in setup" + }, + "setupLanguageSystemDefault": "System Default", + "@setupLanguageSystemDefault": { + "description": "Option to use the system language" + }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" diff --git a/lib/models/track.dart b/lib/models/track.dart index bc6549a6..2e8b8e53 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -25,6 +25,8 @@ class Track { final int? totalTracks; final String? composer; final String? itemType; + final String? audioQuality; + final String? audioModes; const Track({ required this.id, @@ -48,6 +50,8 @@ class Track { this.totalTracks, this.composer, this.itemType, + this.audioQuality, + this.audioModes, }); bool get isSingle { @@ -72,6 +76,11 @@ class Track { Map toJson() => _$TrackToJson(this); bool get isFromExtension => source != null && source!.isNotEmpty; + + bool get isDolbyAtmos => + audioModes != null && audioModes!.contains('DOLBY_ATMOS'); + + bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty; } @JsonSerializable() diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index c889af70..901175bb 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -32,6 +32,8 @@ Track _$TrackFromJson(Map json) => Track( totalTracks: (json['totalTracks'] as num?)?.toInt(), composer: json['composer'] as String?, itemType: json['itemType'] as String?, + audioQuality: json['audioQuality'] as String?, + audioModes: json['audioModes'] as String?, ); Map _$TrackToJson(Track instance) => { @@ -56,6 +58,8 @@ Map _$TrackToJson(Track instance) => { 'totalTracks': instance.totalTracks, 'composer': instance.composer, 'itemType': instance.itemType, + 'audioQuality': instance.audioQuality, + 'audioModes': instance.audioModes, }; ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 33aba515..321975a0 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -851,6 +851,10 @@ class TrackNotifier extends Notifier { albumType: track.albumType, totalTracks: track.totalTracks, source: track.source, + composer: track.composer, + itemType: track.itemType, + audioQuality: track.audioQuality, + audioModes: track.audioModes, availability: ServiceAvailability( tidal: availability['tidal'] as bool? ?? false, qobuz: availability['qobuz'] as bool? ?? false, @@ -932,6 +936,8 @@ class TrackNotifier extends Notifier { albumType: normalizeOptionalString(data['album_type']?.toString()), totalTracks: data['total_tracks'] as int?, composer: data['composer']?.toString(), + audioQuality: data['audio_quality']?.toString(), + audioModes: data['audio_modes']?.toString(), ); } @@ -969,6 +975,8 @@ class TrackNotifier extends Notifier { albumType: normalizeOptionalString(data['album_type']?.toString()), composer: data['composer']?.toString(), itemType: itemType, + audioQuality: data['audio_quality']?.toString(), + audioModes: data['audio_modes']?.toString(), ); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 8256028f..251b183d 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; class _AlbumCache { static final Map _cache = {}; @@ -320,6 +321,8 @@ class _AlbumScreenState extends ConsumerState { totalTracksFallback ?? _albumTotalTracks, composer: data['composer']?.toString(), + audioQuality: data['audio_quality']?.toString(), + audioModes: data['audio_modes']?.toString(), ); } @@ -1012,6 +1015,11 @@ class _AlbumTrackItem extends ConsumerWidget { style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), + ...buildQualityBadges( + audioQuality: track.audioQuality, + audioModes: track.audioModes, + colorScheme: colorScheme, + ), if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), Container( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d717972e..77d15963 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/provider_ui_utils.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -4074,6 +4075,11 @@ class _TrackItemWithStatus extends ConsumerWidget { overflow: TextOverflow.ellipsis, ), ), + ...buildQualityBadges( + audioQuality: track.audioQuality, + audioModes: track.audioModes, + colorScheme: colorScheme, + ), if (isInLocalLibrary) ...[ const SizedBox(width: 6), Container( @@ -4854,6 +4860,8 @@ class _ExtensionAlbumScreenState extends ConsumerState { _albumTotalTracks, composer: data['composer']?.toString(), source: widget.extensionId, + audioQuality: data['audio_quality']?.toString(), + audioModes: data['audio_modes']?.toString(), ); } @@ -5011,6 +5019,8 @@ class _ExtensionPlaylistScreenState totalTracks: data['total_tracks'] as int?, composer: data['composer']?.toString(), source: widget.extensionId, + audioQuality: data['audio_quality']?.toString(), + audioModes: data['audio_modes']?.toString(), ); } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index b1f192eb..426fb512 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -18,6 +18,7 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -208,6 +209,8 @@ class _PlaylistScreenState extends ConsumerState { releaseDate: data['release_date']?.toString(), totalTracks: data['total_tracks'] as int?, composer: data['composer']?.toString(), + audioQuality: data['audio_quality']?.toString(), + audioModes: data['audio_modes']?.toString(), ); } @@ -876,6 +879,11 @@ class _PlaylistTrackItem extends ConsumerWidget { style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), + ...buildQualityBadges( + audioQuality: track.audioQuality, + audioModes: track.audioModes, + colorScheme: colorScheme, + ), if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), Container( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 3ab33731..b894f0c1 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; class SearchScreen extends ConsumerStatefulWidget { final String query; @@ -61,9 +62,7 @@ class _SearchScreenState extends ConsumerState { ); return; } - ref - .read(downloadQueueProvider.notifier) - .addToQueue(track, service); + ref.read(downloadQueueProvider.notifier).addToQueue(track, service); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); @@ -169,6 +168,7 @@ class _SearchScreenState extends ConsumerState { ), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), ); + return ListTile( leading: coverWidget, title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), @@ -183,16 +183,27 @@ class _SearchScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), ), - ClickableAlbumName( - albumName: track.albumName, - albumId: track.albumId, - artistName: track.artistName, - coverUrl: track.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), + Row( + children: [ + Flexible( + child: ClickableAlbumName( + albumName: track.albumName, + albumId: track.albumId, + artistName: track.artistName, + coverUrl: track.coverUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ), + ...buildQualityBadges( + audioQuality: track.audioQuality, + audioModes: track.audioModes, + colorScheme: colorScheme, + ), + ], ), ], ), diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index d373d580..e1469a5e 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/l10n/supported_locales.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -25,6 +26,7 @@ class _SetupScreenState extends ConsumerState { final PageController _pageController = PageController(); int _currentStep = 0; + String _selectedLocale = 'system'; bool _storagePermissionGranted = false; bool _notificationPermissionGranted = false; String? _selectedDirectory; @@ -32,7 +34,7 @@ class _SetupScreenState extends ConsumerState { bool _isLoading = false; int _androidSdkVersion = 0; - int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3; + int get _totalSteps => _androidSdkVersion >= 33 ? 5 : 4; @override void initState() { @@ -483,9 +485,10 @@ class _SetupScreenState extends ConsumerState { } bool _isStepCompleted(int step) { - if (step == 0) return true; + if (step == 0) return true; // Welcome + if (step == 1) return true; // Language (always valid) - final logicStep = step - 1; + final logicStep = step - 2; if (_androidSdkVersion >= 33) { switch (logicStep) { @@ -573,6 +576,7 @@ class _SetupScreenState extends ConsumerState { physics: const NeverScrollableScrollPhysics(), children: [ _buildWelcomeStep(colorScheme), + _buildLanguageStep(colorScheme), _buildStorageStep(colorScheme), if (_androidSdkVersion >= 33) _buildNotificationStep(colorScheme), @@ -671,6 +675,167 @@ class _SetupScreenState extends ConsumerState { ); } + // --- Language data (native names, always readable regardless of current locale) --- + static const _allLanguages = [ + ('system', 'System Default', Icons.phone_android), + ('en', 'English', Icons.language), + ('id', 'Bahasa Indonesia', Icons.language), + ('de', 'Deutsch', Icons.language), + ('es', 'Español', Icons.language), + ('es_ES', 'Español (España)', Icons.language), + ('fr', 'Français', Icons.language), + ('hi', 'हिन्दी', Icons.language), + ('ja', '日本語', Icons.language), + ('ko', '한국어', Icons.language), + ('nl', 'Nederlands', Icons.language), + ('pt', 'Português', Icons.language), + ('pt_PT', 'Português (Brasil)', Icons.language), + ('ru', 'Русский', Icons.language), + ('tr', 'Türkçe', Icons.language), + ('zh', '简体中文', Icons.language), + ('zh_CN', '简体中文 (中国)', Icons.language), + ('zh_TW', '繁體中文', Icons.language), + ]; + + List<(String, String, IconData)> get _filteredLanguages { + return _allLanguages.where((lang) { + if (lang.$1 == 'system') return true; + return filteredLocaleCodes.contains(lang.$1); + }).toList(); + } + + void _onLanguageSelected(String locale) { + setState(() => _selectedLocale = locale); + ref.read(settingsProvider.notifier).setLocale(locale); + } + + Widget _buildLanguageStep(ColorScheme colorScheme) { + final languages = _filteredLanguages; + + return LayoutBuilder( + builder: (context, constraints) { + final shortestSide = MediaQuery.sizeOf(context).shortestSide; + // Match _StepLayout sizing exactly + final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0); + final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0); + final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0); + final descriptionGap = (shortestSide * 0.04).clamp(8.0, 16.0); + final actionGap = (shortestSide * 0.09).clamp(20.0, 48.0); + + return Column( + children: [ + // Header: identical to _StepLayout (same padding, spacing, styles) + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + child: Column( + children: [ + Container( + padding: EdgeInsets.all(iconPadding), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + Icons.translate, + size: iconSize, + color: colorScheme.primary, + ), + ), + SizedBox(height: titleGap), + Text( + context.l10n.setupLanguageTitle, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: descriptionGap), + Text( + context.l10n.setupLanguageDescription, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: actionGap), + ], + ), + ), + // Language list (scrollable action area) + Expanded( + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 80), + itemCount: languages.length, + itemBuilder: (context, index) { + final lang = languages[index]; + final code = lang.$1; + final name = lang.$2; + final isSelected = _selectedLocale == code; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Material( + color: isSelected + ? colorScheme.primaryContainer + : Colors.transparent, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => _onLanguageSelected(code), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + child: Row( + children: [ + Icon( + lang.$3, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + size: 22, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + code == 'system' + ? context.l10n.setupLanguageSystemDefault + : name, + style: TextStyle( + fontSize: 15, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + ), + ), + if (isSelected) + Icon( + Icons.check_circle, + color: colorScheme.onPrimaryContainer, + size: 22, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); + } + Widget _buildStorageStep(ColorScheme colorScheme) { return _StepLayout( title: context.l10n.setupStorageRequired, diff --git a/lib/widgets/audio_quality_badges.dart b/lib/widgets/audio_quality_badges.dart new file mode 100644 index 00000000..d8669584 --- /dev/null +++ b/lib/widgets/audio_quality_badges.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +class AudioQualityBadge extends StatelessWidget { + final String label; + final ColorScheme colorScheme; + + const AudioQualityBadge({ + super.key, + required this.label, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: colorScheme.onPrimaryContainer, + height: 1.3, + ), + ), + ); + } +} + +class DolbyAtmosBadge extends StatelessWidget { + final ColorScheme colorScheme; + + const DolbyAtmosBadge({super.key, required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CustomPaint( + size: const Size(14, 10), + painter: DolbyLogoPainter(color: colorScheme.onTertiaryContainer), + ), + const SizedBox(width: 3), + Text( + 'Atmos', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: colorScheme.onTertiaryContainer, + height: 1.3, + ), + ), + ], + ), + ); + } +} + +class DolbyLogoPainter extends CustomPainter { + final Color color; + + DolbyLogoPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final h = size.height; + final w = size.width; + final cy = h / 2; + + final leftPath = Path() + ..moveTo(w * 0.08, 0) + ..lineTo(w * 0.08, h) + ..lineTo(w * 0.20, h) + ..arcToPoint( + Offset(w * 0.20, 0), + radius: Radius.elliptical(w * 0.25, cy), + clockwise: false, + ) + ..close(); + canvas.drawPath(leftPath, paint); + + final rightPath = Path() + ..moveTo(w * 0.92, 0) + ..lineTo(w * 0.92, h) + ..lineTo(w * 0.80, h) + ..arcToPoint( + Offset(w * 0.80, 0), + radius: Radius.elliptical(w * 0.25, cy), + clockwise: true, + ) + ..close(); + canvas.drawPath(rightPath, paint); + } + + @override + bool shouldRepaint(DolbyLogoPainter oldDelegate) => + color != oldDelegate.color; +} + +/// Convenience builder: returns a list of quality badge widgets for a track. +/// Pass the result into a Row using spread operator. +List buildQualityBadges({ + required String? audioQuality, + required String? audioModes, + required ColorScheme colorScheme, +}) { + final badges = []; + if (audioQuality != null && audioQuality.isNotEmpty) { + badges.add(const SizedBox(width: 6)); + badges.add( + AudioQualityBadge(label: audioQuality, colorScheme: colorScheme), + ); + } + if (audioModes != null && audioModes.contains('DOLBY_ATMOS')) { + badges.add(const SizedBox(width: 4)); + badges.add(DolbyAtmosBadge(colorScheme: colorScheme)); + } + return badges; +}